diff --git a/apps/browser/src/auth/popup/account-switching/services/account-switcher.service.spec.ts b/apps/browser/src/auth/popup/account-switching/services/account-switcher.service.spec.ts index be12d249441..6865adca393 100644 --- a/apps/browser/src/auth/popup/account-switching/services/account-switcher.service.spec.ts +++ b/apps/browser/src/auth/popup/account-switching/services/account-switcher.service.spec.ts @@ -153,7 +153,7 @@ describe("AccountSwitcherService", () => { await selectAccountPromise; - expect(accountService.switchAccount).toBeCalledWith(null); + expect(messagingService.send).toHaveBeenCalledWith("switchAccount", { userId: null }); expect(removeListenerSpy).toBeCalledTimes(1); }); @@ -176,7 +176,7 @@ describe("AccountSwitcherService", () => { await selectAccountPromise; - expect(accountService.switchAccount).toBeCalledWith("1"); + expect(messagingService.send).toHaveBeenCalledWith("switchAccount", { userId: "1" }); expect(messagingService.send).toBeCalledWith( "switchAccount", matches((payload) => { diff --git a/apps/browser/src/auth/popup/account-switching/services/account-switcher.service.ts b/apps/browser/src/auth/popup/account-switching/services/account-switcher.service.ts index 2650c2db4e4..d60b0dfaebc 100644 --- a/apps/browser/src/auth/popup/account-switching/services/account-switcher.service.ts +++ b/apps/browser/src/auth/popup/account-switching/services/account-switcher.service.ts @@ -134,7 +134,6 @@ export class AccountSwitcherService { const switchAccountFinishedPromise = this.listenForSwitchAccountFinish(userId); // Initiate the actions required to make account switching happen - await this.accountService.switchAccount(userId); this.messagingService.send("switchAccount", { userId }); // This message should cause switchAccountFinish to be sent // Wait until we receive the switchAccountFinished message diff --git a/apps/browser/src/autofill/background/overlay.background.spec.ts b/apps/browser/src/autofill/background/overlay.background.spec.ts index e65397a62b1..9f4da8f21f3 100644 --- a/apps/browser/src/autofill/background/overlay.background.spec.ts +++ b/apps/browser/src/autofill/background/overlay.background.spec.ts @@ -1,4 +1,4 @@ -import { mock, mockReset } from "jest-mock-extended"; +import { mock, MockProxy, mockReset } from "jest-mock-extended"; import { BehaviorSubject, of } from "rxjs"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; @@ -62,7 +62,8 @@ describe("OverlayBackground", () => { let overlayBackground: OverlayBackground; const cipherService = mock(); const autofillService = mock(); - const authService = mock(); + let activeAccountStatusMock$: BehaviorSubject; + let authService: MockProxy; const environmentService = mock(); environmentService.environment$ = new BehaviorSubject( @@ -94,6 +95,9 @@ describe("OverlayBackground", () => { beforeEach(() => { domainSettingsService = new DefaultDomainSettingsService(fakeStateProvider); + activeAccountStatusMock$ = new BehaviorSubject(AuthenticationStatus.Unlocked); + authService = mock(); + authService.activeAccountStatus$ = activeAccountStatusMock$; overlayBackground = new OverlayBackground( cipherService, autofillService, @@ -166,11 +170,11 @@ describe("OverlayBackground", () => { }); beforeEach(() => { - overlayBackground["userAuthStatus"] = AuthenticationStatus.Unlocked; + activeAccountStatusMock$.next(AuthenticationStatus.Unlocked); }); it("ignores updating the overlay ciphers if the user's auth status is not unlocked", async () => { - overlayBackground["userAuthStatus"] = AuthenticationStatus.Locked; + activeAccountStatusMock$.next(AuthenticationStatus.Locked); jest.spyOn(BrowserApi, "getTabFromCurrentWindowId"); jest.spyOn(cipherService, "getAllDecryptedForUrl"); diff --git a/apps/browser/src/autofill/background/overlay.background.ts b/apps/browser/src/autofill/background/overlay.background.ts index 551263525e9..0e4abcd82d0 100644 --- a/apps/browser/src/autofill/background/overlay.background.ts +++ b/apps/browser/src/autofill/background/overlay.background.ts @@ -136,7 +136,8 @@ class OverlayBackground implements OverlayBackgroundInterface { * list of ciphers if the extension is not unlocked. */ async updateOverlayCiphers() { - if (this.userAuthStatus !== AuthenticationStatus.Unlocked) { + const authStatus = await firstValueFrom(this.authService.activeAccountStatus$); + if (authStatus !== AuthenticationStatus.Unlocked) { return; } @@ -167,7 +168,7 @@ class OverlayBackground implements OverlayBackgroundInterface { private async getOverlayCipherData(): Promise { const showFavicons = await firstValueFrom(this.domainSettingsService.showFavicons$); const overlayCiphersArray = Array.from(this.overlayLoginCiphers); - const overlayCipherData = []; + const overlayCipherData: OverlayCipherData[] = []; let loginCipherIcon: WebsiteIconData; for (let cipherIndex = 0; cipherIndex < overlayCiphersArray.length; cipherIndex++) { diff --git a/apps/browser/src/autofill/services/autofill.service.ts b/apps/browser/src/autofill/services/autofill.service.ts index dd875054414..80829ee7141 100644 --- a/apps/browser/src/autofill/services/autofill.service.ts +++ b/apps/browser/src/autofill/services/autofill.service.ts @@ -3,6 +3,7 @@ import { firstValueFrom } from "rxjs"; import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; +import { AutofillOverlayVisibility } from "@bitwarden/common/autofill/constants"; import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service"; import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service"; import { InlineMenuVisibilitySetting } from "@bitwarden/common/autofill/types"; @@ -109,9 +110,12 @@ export default class AutofillService implements AutofillServiceInterface { // Autofill user settings loaded from state can await the active account state indefinitely // if not guarded by an active account check (e.g. the user is logged in) const activeAccount = await firstValueFrom(this.accountService.activeAccount$); - + let overlayVisibility: InlineMenuVisibilitySetting = AutofillOverlayVisibility.Off; let autoFillOnPageLoadIsEnabled = false; - const overlayVisibility = await this.getOverlayVisibility(); + + if (activeAccount) { + overlayVisibility = await this.getOverlayVisibility(); + } const mainAutofillScript = overlayVisibility ? "bootstrap-autofill-overlay.js" diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index 4d1307b4075..90a018a8fe8 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -144,13 +144,11 @@ import { NotificationsService } from "@bitwarden/common/services/notifications.s import { SearchService } from "@bitwarden/common/services/search.service"; import { VaultTimeoutSettingsService } from "@bitwarden/common/services/vault-timeout/vault-timeout-settings.service"; import { - PasswordGenerationService, - PasswordGenerationServiceAbstraction, -} from "@bitwarden/common/tools/generator/password"; -import { - UsernameGenerationService, - UsernameGenerationServiceAbstraction, -} from "@bitwarden/common/tools/generator/username"; + legacyPasswordGenerationServiceFactory, + legacyUsernameGenerationServiceFactory, +} from "@bitwarden/common/tools/generator"; +import { PasswordGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/password"; +import { UsernameGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/username"; import { PasswordStrengthService, PasswordStrengthServiceAbstraction, @@ -649,10 +647,12 @@ export default class MainBackground { this.passwordStrengthService = new PasswordStrengthService(); - this.passwordGenerationService = new PasswordGenerationService( + this.passwordGenerationService = legacyPasswordGenerationServiceFactory( + this.encryptService, this.cryptoService, this.policyService, - this.stateService, + this.accountService, + this.stateProvider, ); this.userDecryptionOptionsService = new UserDecryptionOptionsService(this.stateProvider); @@ -1092,10 +1092,14 @@ export default class MainBackground { this.vaultTimeoutSettingsService, ); - this.usernameGenerationService = new UsernameGenerationService( - this.cryptoService, - this.stateService, + this.usernameGenerationService = legacyUsernameGenerationServiceFactory( this.apiService, + this.i18nService, + this.cryptoService, + this.encryptService, + this.policyService, + this.accountService, + this.stateProvider, ); if (!this.popupOnlyContext) { diff --git a/apps/browser/src/platform/offscreen-document/abstractions/offscreen-document.ts b/apps/browser/src/platform/offscreen-document/abstractions/offscreen-document.ts index 2d3c6a3e713..012f908f784 100644 --- a/apps/browser/src/platform/offscreen-document/abstractions/offscreen-document.ts +++ b/apps/browser/src/platform/offscreen-document/abstractions/offscreen-document.ts @@ -20,6 +20,7 @@ export interface OffscreenDocument { } export abstract class OffscreenDocumentService { + abstract offscreenApiSupported(): boolean; abstract withDocument( reasons: chrome.offscreen.Reason[], justification: string, diff --git a/apps/browser/src/platform/offscreen-document/offscreen-document.service.spec.ts b/apps/browser/src/platform/offscreen-document/offscreen-document.service.spec.ts index c9bdd823a52..da541403967 100644 --- a/apps/browser/src/platform/offscreen-document/offscreen-document.service.spec.ts +++ b/apps/browser/src/platform/offscreen-document/offscreen-document.service.spec.ts @@ -49,6 +49,12 @@ describe.each([ jest.resetAllMocks(); }); + describe("offscreenApiSupported", () => { + it("indicates whether the offscreen API is supported", () => { + expect(sut.offscreenApiSupported()).toBe(true); + }); + }); + describe("withDocument", () => { it("creates a document when none exists", async () => { await sut.withDocument(reasons, justification, () => {}); diff --git a/apps/browser/src/platform/offscreen-document/offscreen-document.service.ts b/apps/browser/src/platform/offscreen-document/offscreen-document.service.ts index a260e3ca6c8..3a1227ea5e2 100644 --- a/apps/browser/src/platform/offscreen-document/offscreen-document.service.ts +++ b/apps/browser/src/platform/offscreen-document/offscreen-document.service.ts @@ -1,10 +1,16 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; -export class DefaultOffscreenDocumentService implements DefaultOffscreenDocumentService { +import { OffscreenDocumentService } from "./abstractions/offscreen-document"; + +export class DefaultOffscreenDocumentService implements OffscreenDocumentService { private workerCount = 0; constructor(private logService: LogService) {} + offscreenApiSupported(): boolean { + return typeof chrome.offscreen !== "undefined"; + } + async withDocument( reasons: chrome.offscreen.Reason[], justification: string, diff --git a/apps/browser/src/platform/services/platform-utils/browser-platform-utils.service.spec.ts b/apps/browser/src/platform/services/platform-utils/browser-platform-utils.service.spec.ts index 02c10b62cc4..c86c9158019 100644 --- a/apps/browser/src/platform/services/platform-utils/browser-platform-utils.service.spec.ts +++ b/apps/browser/src/platform/services/platform-utils/browser-platform-utils.service.spec.ts @@ -229,9 +229,7 @@ describe("Browser Utils Service", () => { it("copies the passed text using the offscreen document if the extension is using manifest v3", async () => { const text = "test"; - jest - .spyOn(browserPlatformUtilsService, "getDevice") - .mockReturnValue(DeviceType.ChromeExtension); + offscreenDocumentService.offscreenApiSupported.mockReturnValue(true); getManifestVersionSpy.mockReturnValue(3); browserPlatformUtilsService.copyToClipboard(text); @@ -304,9 +302,7 @@ describe("Browser Utils Service", () => { }); it("reads the clipboard text using the offscreen document", async () => { - jest - .spyOn(browserPlatformUtilsService, "getDevice") - .mockReturnValue(DeviceType.ChromeExtension); + offscreenDocumentService.offscreenApiSupported.mockReturnValue(true); getManifestVersionSpy.mockReturnValue(3); offscreenDocumentService.withDocument.mockImplementationOnce((_, __, callback) => Promise.resolve("test"), diff --git a/apps/browser/src/platform/services/platform-utils/browser-platform-utils.service.ts b/apps/browser/src/platform/services/platform-utils/browser-platform-utils.service.ts index 4163ca93107..26108e60b7e 100644 --- a/apps/browser/src/platform/services/platform-utils/browser-platform-utils.service.ts +++ b/apps/browser/src/platform/services/platform-utils/browser-platform-utils.service.ts @@ -243,7 +243,7 @@ export abstract class BrowserPlatformUtilsService implements PlatformUtilsServic text = "\u0000"; } - if (this.isChrome() && BrowserApi.isManifestVersion(3)) { + if (BrowserApi.isManifestVersion(3) && this.offscreenDocumentService.offscreenApiSupported()) { void this.triggerOffscreenCopyToClipboard(text).then(handleClipboardWriteCallback); return; @@ -268,7 +268,7 @@ export abstract class BrowserPlatformUtilsService implements PlatformUtilsServic return await SafariApp.sendMessageToApp("readFromClipboard"); } - if (this.isChrome() && BrowserApi.isManifestVersion(3)) { + if (BrowserApi.isManifestVersion(3) && this.offscreenDocumentService.offscreenApiSupported()) { return await this.triggerOffscreenReadFromClipboard(); } diff --git a/apps/browser/src/tools/popup/generator/generator.component.ts b/apps/browser/src/tools/popup/generator/generator.component.ts index fbe02d34f56..1afe696576f 100644 --- a/apps/browser/src/tools/popup/generator/generator.component.ts +++ b/apps/browser/src/tools/popup/generator/generator.component.ts @@ -1,5 +1,5 @@ import { Location } from "@angular/common"; -import { Component } from "@angular/core"; +import { Component, NgZone } from "@angular/core"; import { ActivatedRoute } from "@angular/router"; import { firstValueFrom } from "rxjs"; @@ -8,7 +8,6 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { PasswordGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/password"; import { UsernameGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/username"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; @@ -29,22 +28,22 @@ export class GeneratorComponent extends BaseGeneratorComponent { usernameGenerationService: UsernameGenerationServiceAbstraction, platformUtilsService: PlatformUtilsService, i18nService: I18nService, - stateService: StateService, + accountService: AccountService, cipherService: CipherService, route: ActivatedRoute, logService: LogService, - accountService: AccountService, + ngZone: NgZone, private location: Location, ) { super( passwordGenerationService, usernameGenerationService, platformUtilsService, - stateService, + accountService, i18nService, logService, route, - accountService, + ngZone, window, ); this.cipherService = cipherService; diff --git a/apps/browser/src/tools/popup/settings/export.component.html b/apps/browser/src/tools/popup/settings/export.component.html index 1b2ea1eb1d5..ef031b7979a 100644 --- a/apps/browser/src/tools/popup/settings/export.component.html +++ b/apps/browser/src/tools/popup/settings/export.component.html @@ -29,11 +29,6 @@ - - - - diff --git a/apps/browser/src/tools/popup/settings/export.component.ts b/apps/browser/src/tools/popup/settings/export.component.ts index b62ed4c517f..9f3f054d2ac 100644 --- a/apps/browser/src/tools/popup/settings/export.component.ts +++ b/apps/browser/src/tools/popup/settings/export.component.ts @@ -5,7 +5,6 @@ import { Router } from "@angular/router"; import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; -import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -27,7 +26,6 @@ export class ExportComponent extends BaseExportComponent { policyService: PolicyService, private router: Router, logService: LogService, - userVerificationService: UserVerificationService, formBuilder: UntypedFormBuilder, fileDownloadService: FileDownloadService, dialogService: DialogService, @@ -40,7 +38,6 @@ export class ExportComponent extends BaseExportComponent { eventCollectionService, policyService, logService, - userVerificationService, formBuilder, fileDownloadService, dialogService, diff --git a/apps/cli/src/service-container.ts b/apps/cli/src/service-container.ts index cffdc53444e..a47a943724f 100644 --- a/apps/cli/src/service-container.ts +++ b/apps/cli/src/service-container.ts @@ -103,10 +103,8 @@ import { EventUploadService } from "@bitwarden/common/services/event/event-uploa import { SearchService } from "@bitwarden/common/services/search.service"; import { VaultTimeoutSettingsService } from "@bitwarden/common/services/vault-timeout/vault-timeout-settings.service"; import { VaultTimeoutService } from "@bitwarden/common/services/vault-timeout/vault-timeout.service"; -import { - PasswordGenerationService, - PasswordGenerationServiceAbstraction, -} from "@bitwarden/common/tools/generator/password"; +import { legacyPasswordGenerationServiceFactory } from "@bitwarden/common/tools/generator"; +import { PasswordGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/password"; import { PasswordStrengthService, PasswordStrengthServiceAbstraction, @@ -499,10 +497,12 @@ export class ServiceContainer { this.passwordStrengthService = new PasswordStrengthService(); - this.passwordGenerationService = new PasswordGenerationService( + this.passwordGenerationService = legacyPasswordGenerationServiceFactory( + this.encryptService, this.cryptoService, this.policyService, - this.stateService, + this.accountService, + this.stateProvider, ); this.devicesApiService = new DevicesApiServiceImplementation(this.apiService); diff --git a/apps/desktop/src/app/app.component.ts b/apps/desktop/src/app/app.component.ts index e77ef8d3f07..901d7a9c5cc 100644 --- a/apps/desktop/src/app/app.component.ts +++ b/apps/desktop/src/app/app.component.ts @@ -411,7 +411,8 @@ export class AppComponent implements OnInit, OnDestroy { this.masterPasswordService.forceSetPasswordReason$(message.userId), )) != ForceSetPasswordReason.None; if (locked) { - this.messagingService.send("locked", { userId: message.userId }); + this.modalService.closeAll(); + await this.router.navigate(["lock"]); } else if (forcedPasswordReset) { // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. // eslint-disable-next-line @typescript-eslint/no-floating-promises diff --git a/apps/desktop/src/app/tools/export/export.component.html b/apps/desktop/src/app/tools/export/export.component.html index 0058a0925c2..3792713e61e 100644 --- a/apps/desktop/src/app/tools/export/export.component.html +++ b/apps/desktop/src/app/tools/export/export.component.html @@ -21,11 +21,6 @@ - - - - diff --git a/apps/desktop/src/app/tools/export/export.component.ts b/apps/desktop/src/app/tools/export/export.component.ts index 80ae3c80f96..6cf5760a1cb 100644 --- a/apps/desktop/src/app/tools/export/export.component.ts +++ b/apps/desktop/src/app/tools/export/export.component.ts @@ -4,7 +4,6 @@ import { UntypedFormBuilder } from "@angular/forms"; import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; -import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -24,7 +23,6 @@ export class ExportComponent extends BaseExportComponent implements OnInit { exportService: VaultExportServiceAbstraction, eventCollectionService: EventCollectionService, policyService: PolicyService, - userVerificationService: UserVerificationService, formBuilder: UntypedFormBuilder, logService: LogService, fileDownloadService: FileDownloadService, @@ -38,7 +36,6 @@ export class ExportComponent extends BaseExportComponent implements OnInit { eventCollectionService, policyService, logService, - userVerificationService, formBuilder, fileDownloadService, dialogService, diff --git a/apps/desktop/src/app/tools/generator.component.spec.ts b/apps/desktop/src/app/tools/generator.component.spec.ts index d908de8ef77..dff7da96004 100644 --- a/apps/desktop/src/app/tools/generator.component.spec.ts +++ b/apps/desktop/src/app/tools/generator.component.spec.ts @@ -8,7 +8,6 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { PasswordGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/password"; import { UsernameGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/username"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; @@ -36,10 +35,6 @@ describe("GeneratorComponent", () => { provide: UsernameGenerationServiceAbstraction, useValue: mock(), }, - { - provide: StateService, - useValue: mock(), - }, { provide: PlatformUtilsService, useValue: platformUtilsServiceMock, diff --git a/apps/desktop/src/app/tools/generator.component.ts b/apps/desktop/src/app/tools/generator.component.ts index 5bc53289438..a5c3d393877 100644 --- a/apps/desktop/src/app/tools/generator.component.ts +++ b/apps/desktop/src/app/tools/generator.component.ts @@ -1,4 +1,4 @@ -import { Component } from "@angular/core"; +import { Component, NgZone } from "@angular/core"; import { ActivatedRoute } from "@angular/router"; import { GeneratorComponent as BaseGeneratorComponent } from "@bitwarden/angular/tools/generator/components/generator.component"; @@ -6,7 +6,6 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { PasswordGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/password"; import { UsernameGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/username"; @@ -18,22 +17,22 @@ export class GeneratorComponent extends BaseGeneratorComponent { constructor( passwordGenerationService: PasswordGenerationServiceAbstraction, usernameGenerationService: UsernameGenerationServiceAbstraction, - stateService: StateService, + accountService: AccountService, platformUtilsService: PlatformUtilsService, i18nService: I18nService, route: ActivatedRoute, + ngZone: NgZone, logService: LogService, - accountService: AccountService, ) { super( passwordGenerationService, usernameGenerationService, platformUtilsService, - stateService, + accountService, i18nService, logService, route, - accountService, + ngZone, window, ); } diff --git a/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-confirm.component.html b/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-confirm.component.html index 7f539f098ba..0f420c6903b 100644 --- a/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-confirm.component.html +++ b/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-confirm.component.html @@ -1,115 +1,99 @@ - +

+ + + + {{ "user" | i18n }} + {{ "fingerprint" | i18n }} + + + + + + + + + {{ user.email }} +

{{ user.name }}

+ + + {{ fingerprints.get(user.id) }} + + + + + + + + {{ user.email }} +

{{ user.name }}

+ + + {{ "bulkFilteredMessage" | i18n }} + + +
+
+ + + + + + {{ "user" | i18n }} + {{ "status" | i18n }} + + + + + + + + + {{ user.email }} +

{{ user.name }}

+ + + {{ statuses.get(user.id) }} + + + {{ "bulkFilteredMessage" | i18n }} + + +
+
+
+ + + + + + diff --git a/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-confirm.component.ts b/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-confirm.component.ts index 1d268990173..d94edd55f85 100644 --- a/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-confirm.component.ts +++ b/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-confirm.component.ts @@ -1,4 +1,5 @@ -import { Component, Input, OnInit } from "@angular/core"; +import { DIALOG_DATA, DialogConfig } from "@angular/cdk/dialog"; +import { Component, Inject, OnInit } from "@angular/core"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { OrganizationUserService } from "@bitwarden/common/admin-console/abstractions/organization-user/organization-user.service"; @@ -8,16 +9,22 @@ import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.se import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; +import { DialogService } from "@bitwarden/components"; import { BulkUserDetails } from "./bulk-status.component"; +type BulkConfirmDialogData = { + organizationId: string; + users: BulkUserDetails[]; +}; + @Component({ selector: "app-bulk-confirm", templateUrl: "bulk-confirm.component.html", }) export class BulkConfirmComponent implements OnInit { - @Input() organizationId: string; - @Input() users: BulkUserDetails[]; + organizationId: string; + users: BulkUserDetails[]; excludedUsers: BulkUserDetails[]; filteredUsers: BulkUserDetails[]; @@ -30,11 +37,15 @@ export class BulkConfirmComponent implements OnInit { error: string; constructor( + @Inject(DIALOG_DATA) protected data: BulkConfirmDialogData, protected cryptoService: CryptoService, protected apiService: ApiService, private organizationUserService: OrganizationUserService, private i18nService: I18nService, - ) {} + ) { + this.organizationId = data.organizationId; + this.users = data.users; + } async ngOnInit() { this.excludedUsers = this.users.filter((u) => !this.isAccepted(u)); @@ -110,4 +121,8 @@ export class BulkConfirmComponent implements OnInit { request, ); } + + static open(dialogService: DialogService, config: DialogConfig) { + return dialogService.open(BulkConfirmComponent, config); + } } diff --git a/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-remove.component.html b/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-remove.component.html index d2adef98ee3..3bcc749821a 100644 --- a/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-remove.component.html +++ b/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-remove.component.html @@ -1,101 +1,88 @@ - + + + + + + + {{ user.email }} + {{ user.name }} + + + {{ statuses.get(user.id) }} + + + {{ "bulkFilteredMessage" | i18n }} + + + + + + + + + + + diff --git a/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-remove.component.ts b/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-remove.component.ts index fdf499f0398..15a24eb25eb 100644 --- a/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-remove.component.ts +++ b/apps/web/src/app/admin-console/organizations/members/components/bulk/bulk-remove.component.ts @@ -1,30 +1,26 @@ -import { Component, Input } from "@angular/core"; +import { DIALOG_DATA, DialogConfig } from "@angular/cdk/dialog"; +import { Component, Inject } from "@angular/core"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { OrganizationUserService } from "@bitwarden/common/admin-console/abstractions/organization-user/organization-user.service"; import { OrganizationUserStatusType } from "@bitwarden/common/admin-console/enums"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { DialogService } from "@bitwarden/components"; import { BulkUserDetails } from "./bulk-status.component"; +type BulkRemoveDialogData = { + organizationId: string; + users: BulkUserDetails[]; +}; + @Component({ selector: "app-bulk-remove", templateUrl: "bulk-remove.component.html", }) export class BulkRemoveComponent { - @Input() organizationId: string; - @Input() set users(value: BulkUserDetails[]) { - this._users = value; - this.showNoMasterPasswordWarning = this._users.some( - (u) => u.status > OrganizationUserStatusType.Invited && u.hasMasterPassword === false, - ); - } - - get users(): BulkUserDetails[] { - return this._users; - } - - private _users: BulkUserDetails[]; + organizationId: string; + users: BulkUserDetails[]; statuses: Map = new Map(); @@ -34,12 +30,19 @@ export class BulkRemoveComponent { showNoMasterPasswordWarning = false; constructor( + @Inject(DIALOG_DATA) protected data: BulkRemoveDialogData, protected apiService: ApiService, protected i18nService: I18nService, private organizationUserService: OrganizationUserService, - ) {} + ) { + this.organizationId = data.organizationId; + this.users = data.users; + this.showNoMasterPasswordWarning = this.users.some( + (u) => u.status > OrganizationUserStatusType.Invited && u.hasMasterPassword === false, + ); + } - async submit() { + submit = async () => { this.loading = true; try { const response = await this.deleteUsers(); @@ -54,7 +57,7 @@ export class BulkRemoveComponent { } this.loading = false; - } + }; protected async deleteUsers() { return await this.organizationUserService.deleteManyOrganizationUsers( @@ -66,4 +69,8 @@ export class BulkRemoveComponent { protected get removeUsersWarning() { return this.i18nService.t("removeOrgUsersConfirmation"); } + + static open(dialogService: DialogService, config: DialogConfig) { + return dialogService.open(BulkRemoveComponent, config); + } } diff --git a/apps/web/src/app/admin-console/organizations/members/components/member-dialog/member-dialog.component.html b/apps/web/src/app/admin-console/organizations/members/components/member-dialog/member-dialog.component.html index 2719666cc8c..35e28b5239e 100644 --- a/apps/web/src/app/admin-console/organizations/members/components/member-dialog/member-dialog.component.html +++ b/apps/web/src/app/admin-console/organizations/members/components/member-dialog/member-dialog.component.html @@ -14,7 +14,7 @@ title="{{ 'loading' | i18n }}" aria-hidden="true" > - {{ "loading" | i18n }} + {{ "loading" | i18n }} -

{{ "inviteUserDesc" | i18n }}

+

{{ "inviteUserDesc" | i18n }}

{{ "email" | i18n }} @@ -32,13 +32,11 @@ }}
-
- + + {{ "memberRole" | i18n }} - -
- - -
-
+ + {{ "user" | i18n }} + {{ "userDesc" | i18n }} + + - - -
-
- - -
-
- - -
-
- - -
-
+ + + -

+

{{ "permissions" | i18n }}

-
-
-
- +
+
+
+ {{ + "managerPermissions" | i18n + }}
-
-
- +
+
+ {{ "adminPermissions" | i18n }}
-
- - -
-
- - -
-
- - -
+ + + {{ "accessEventLogs" | i18n }} + + + + {{ "accessImportExport" | i18n }} + + + + {{ "accessReports" | i18n }} + -
+ + + {{ "manageGroups" | i18n }} + + + + {{ "manageSso" | i18n }} + + + + {{ "managePolicies" | i18n }} + + - -
-
- - -
-
- - -
-
- - -
-
+ {{ "manageUsers" | i18n }} + + - -
+ {{ "manageAccountRecovery" | i18n }} +
-
-
-
- - -
-
- - -
-
- - -
+
+
+ + + {{ "accessEventLogs" | i18n }} + + + + {{ "accessImportExport" | i18n }} + + + + {{ "accessReports" | i18n }} +
-
+
-
-
-
+
+
+ + + {{ "manageGroups" | i18n }} + + + + {{ "manageSso" | i18n }} + + + + {{ "managePolicies" | i18n }} + + - -
-
- - -
-
- - -
-
- - -
-
+ {{ "manageUsers" | i18n }} + + - -
+ {{ "manageAccountRecovery" | i18n }} +
-

+

{{ "secretsManager" | i18n }} {{ "accessAllCollectionsDesc" | i18n }} - - -
+ + + {{ parentId | i18n }} + +
-
- - +
+ + + {{ c.key | i18n }} +
diff --git a/apps/web/src/app/admin-console/organizations/members/people.component.ts b/apps/web/src/app/admin-console/organizations/members/people.component.ts index af04d83c34d..d776ac1e5df 100644 --- a/apps/web/src/app/admin-console/organizations/members/people.component.ts +++ b/apps/web/src/app/admin-console/organizations/members/people.component.ts @@ -481,16 +481,13 @@ export class PeopleComponent extends BasePeopleComponent { return; } - const [modal] = await this.modalService.openViewRef( - BulkRemoveComponent, - this.bulkRemoveModalRef, - (comp) => { - comp.organizationId = this.organization.id; - comp.users = this.getCheckedUsers(); + const dialogRef = BulkRemoveComponent.open(this.dialogService, { + data: { + organizationId: this.organization.id, + users: this.getCheckedUsers(), }, - ); - - await modal.onClosedPromise(); + }); + await lastValueFrom(dialogRef.closed); await this.load(); } @@ -558,16 +555,14 @@ export class PeopleComponent extends BasePeopleComponent { return; } - const [modal] = await this.modalService.openViewRef( - BulkConfirmComponent, - this.bulkConfirmModalRef, - (comp) => { - comp.organizationId = this.organization.id; - comp.users = this.getCheckedUsers(); + const dialogRef = BulkConfirmComponent.open(this.dialogService, { + data: { + organizationId: this.organization.id, + users: this.getCheckedUsers(), }, - ); + }); - await modal.onClosedPromise(); + await lastValueFrom(dialogRef.closed); await this.load(); } diff --git a/apps/web/src/app/admin-console/organizations/policies/disable-send.component.html b/apps/web/src/app/admin-console/organizations/policies/disable-send.component.html index d30ee4997a9..0e50c5838ee 100644 --- a/apps/web/src/app/admin-console/organizations/policies/disable-send.component.html +++ b/apps/web/src/app/admin-console/organizations/policies/disable-send.component.html @@ -2,15 +2,7 @@ {{ "disableSendExemption" | i18n }} -
-
- - -
-
+ + + {{ "turnOn" | i18n }} + diff --git a/apps/web/src/app/admin-console/organizations/policies/password-generator.component.html b/apps/web/src/app/admin-console/organizations/policies/password-generator.component.html index 350f7da7b4b..80df5fa2e6a 100644 --- a/apps/web/src/app/admin-console/organizations/policies/password-generator.component.html +++ b/apps/web/src/app/admin-console/organizations/policies/password-generator.component.html @@ -1,144 +1,64 @@
-
-
- - -
+ + + {{ "turnOn" | i18n }} + + +
+ + {{ "defaultType" | i18n }} + + + +
-
-
- - -
+

{{ "password" | i18n }}

+
+ + {{ "minLength" | i18n }} + +
-

{{ "password" | i18n }}

-
-
- - -
+
+ + {{ "minNumbers" | i18n }} + + + + {{ "minSpecial" | i18n }} + +
-
-
- - -
-
- - -
-
-
- - -
-
- - -
-
- - -
-
- - -
-

{{ "passphrase" | i18n }}

-
-
- - -
-
-
- - -
-
- - + + + A-Z + + + + a-z + + + + 0-9 + + + + !@#$%^&* + +

{{ "passphrase" | i18n }}

+
+ + {{ "minimumNumberOfWords" | i18n }} + +
+ + + {{ "capitalize" | i18n }} + + + + {{ "includeNumber" | i18n }} +
diff --git a/apps/web/src/app/admin-console/organizations/policies/password-generator.component.ts b/apps/web/src/app/admin-console/organizations/policies/password-generator.component.ts index 464586d6855..93124c42fa6 100644 --- a/apps/web/src/app/admin-console/organizations/policies/password-generator.component.ts +++ b/apps/web/src/app/admin-console/organizations/policies/password-generator.component.ts @@ -1,5 +1,5 @@ import { Component } from "@angular/core"; -import { UntypedFormBuilder } from "@angular/forms"; +import { UntypedFormBuilder, Validators } from "@angular/forms"; import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -20,14 +20,14 @@ export class PasswordGeneratorPolicy extends BasePolicy { export class PasswordGeneratorPolicyComponent extends BasePolicyComponent { data = this.formBuilder.group({ defaultType: [null], - minLength: [null], + minLength: [null, [Validators.min(5), Validators.max(128)]], useUpper: [null], useLower: [null], useNumbers: [null], useSpecial: [null], - minNumbers: [null], - minSpecial: [null], - minNumberWords: [null], + minNumbers: [null, [Validators.min(0), Validators.max(9)]], + minSpecial: [null, [Validators.min(0), Validators.max(9)]], + minNumberWords: [null, [Validators.min(3), Validators.max(20)]], capitalize: [null], includeNumber: [null], }); diff --git a/apps/web/src/app/admin-console/organizations/policies/require-sso.component.html b/apps/web/src/app/admin-console/organizations/policies/require-sso.component.html index 095b12ff366..0056f654d01 100644 --- a/apps/web/src/app/admin-console/organizations/policies/require-sso.component.html +++ b/apps/web/src/app/admin-console/organizations/policies/require-sso.component.html @@ -5,15 +5,7 @@ {{ "requireSsoExemption" | i18n }} -
-
- - -
-
+ + + {{ "turnOn" | i18n }} + diff --git a/apps/web/src/app/admin-console/organizations/policies/send-options.component.html b/apps/web/src/app/admin-console/organizations/policies/send-options.component.html index c2e5ae8a34c..7bf34ef3a69 100644 --- a/apps/web/src/app/admin-console/organizations/policies/send-options.component.html +++ b/apps/web/src/app/admin-console/organizations/policies/send-options.component.html @@ -2,29 +2,15 @@ {{ "sendOptionsExemption" | i18n }} -
-
- - -
-
+ + + {{ "turnOn" | i18n }} +
-

{{ "options" | i18n }}

-
- - -
+

{{ "options" | i18n }}

+ + + {{ "disableHideEmail" | i18n }} +
diff --git a/apps/web/src/app/admin-console/organizations/policies/single-org.component.html b/apps/web/src/app/admin-console/organizations/policies/single-org.component.html index a270adcfc00..aaf6888eddb 100644 --- a/apps/web/src/app/admin-console/organizations/policies/single-org.component.html +++ b/apps/web/src/app/admin-console/organizations/policies/single-org.component.html @@ -2,15 +2,7 @@ {{ "singleOrgPolicyWarning" | i18n }} -
-
- - -
-
+ + + {{ "turnOn" | i18n }} + diff --git a/apps/web/src/app/admin-console/organizations/settings/account.component.html b/apps/web/src/app/admin-console/organizations/settings/account.component.html index 082fe7eb80b..d8cd5fddfe9 100644 --- a/apps/web/src/app/admin-console/organizations/settings/account.component.html +++ b/apps/web/src/app/admin-console/organizations/settings/account.component.html @@ -104,12 +104,11 @@ - - diff --git a/apps/web/src/app/admin-console/organizations/settings/account.component.ts b/apps/web/src/app/admin-console/organizations/settings/account.component.ts index d8091e46aef..5ff8d00eab4 100644 --- a/apps/web/src/app/admin-console/organizations/settings/account.component.ts +++ b/apps/web/src/app/admin-console/organizations/settings/account.component.ts @@ -28,8 +28,6 @@ import { DeleteOrganizationDialogResult, openDeleteOrganizationDialog } from "./ templateUrl: "account.component.html", }) export class AccountComponent { - @ViewChild("purgeOrganizationTemplate", { read: ViewContainerRef, static: true }) - purgeModalRef: ViewContainerRef; @ViewChild("apiKeyTemplate", { read: ViewContainerRef, static: true }) apiKeyModalRef: ViewContainerRef; @ViewChild("rotateApiKeyTemplate", { read: ViewContainerRef, static: true }) @@ -232,11 +230,14 @@ export class AccountComponent { } } - async purgeVault() { - await this.modalService.openViewRef(PurgeVaultComponent, this.purgeModalRef, (comp) => { - comp.organizationId = this.organizationId; + purgeVault = async () => { + const dialogRef = PurgeVaultComponent.open(this.dialogService, { + data: { + organizationId: this.organizationId, + }, }); - } + await lastValueFrom(dialogRef.closed); + }; async viewApiKey() { await this.modalService.openViewRef(ApiKeyComponent, this.apiKeyModalRef, (comp) => { diff --git a/apps/web/src/app/admin-console/organizations/tools/vault-export/org-vault-export.component.ts b/apps/web/src/app/admin-console/organizations/tools/vault-export/org-vault-export.component.ts index 7a2a67b69c9..e3d19f5487c 100644 --- a/apps/web/src/app/admin-console/organizations/tools/vault-export/org-vault-export.component.ts +++ b/apps/web/src/app/admin-console/organizations/tools/vault-export/org-vault-export.component.ts @@ -5,7 +5,6 @@ import { ActivatedRoute } from "@angular/router"; import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; -import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; import { EventType } from "@bitwarden/common/enums"; import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -30,7 +29,6 @@ export class OrganizationVaultExportComponent extends ExportComponent { private route: ActivatedRoute, policyService: PolicyService, logService: LogService, - userVerificationService: UserVerificationService, formBuilder: UntypedFormBuilder, fileDownloadService: FileDownloadService, dialogService: DialogService, @@ -43,7 +41,6 @@ export class OrganizationVaultExportComponent extends ExportComponent { eventCollectionService, policyService, logService, - userVerificationService, formBuilder, fileDownloadService, dialogService, diff --git a/apps/web/src/app/admin-console/settings/sponsoring-org-row.component.html b/apps/web/src/app/admin-console/settings/sponsoring-org-row.component.html index 4be0f8d94ca..b07cbbfad12 100644 --- a/apps/web/src/app/admin-console/settings/sponsoring-org-row.component.html +++ b/apps/web/src/app/admin-console/settings/sponsoring-org-row.component.html @@ -1,50 +1,36 @@ - + {{ sponsoringOrg.familySponsorshipFriendlyName }} -{{ sponsoringOrg.name }} - +{{ sponsoringOrg.name }} + {{ statusMessage }} - - + + diff --git a/apps/web/src/app/admin-console/settings/sponsoring-org-row.component.ts b/apps/web/src/app/admin-console/settings/sponsoring-org-row.component.ts index 0ef8850ac96..eff75b61b39 100644 --- a/apps/web/src/app/admin-console/settings/sponsoring-org-row.component.ts +++ b/apps/web/src/app/admin-console/settings/sponsoring-org-row.component.ts @@ -20,10 +20,7 @@ export class SponsoringOrgRowComponent implements OnInit { @Output() sponsorshipRemoved = new EventEmitter(); statusMessage = "loading"; - statusClass: "text-success" | "text-danger" = "text-success"; - - revokeSponsorshipPromise: Promise; - resendEmailPromise: Promise; + statusClass: "tw-text-success" | "tw-text-danger" = "tw-text-success"; private locale = ""; @@ -48,20 +45,15 @@ export class SponsoringOrgRowComponent implements OnInit { async revokeSponsorship() { try { - this.revokeSponsorshipPromise = this.doRevokeSponsorship(); - await this.revokeSponsorshipPromise; + await this.doRevokeSponsorship(); } catch (e) { this.logService.error(e); } - - this.revokeSponsorshipPromise = null; } async resendEmail() { - this.resendEmailPromise = this.apiService.postResendSponsorshipOffer(this.sponsoringOrg.id); - await this.resendEmailPromise; + await this.apiService.postResendSponsorshipOffer(this.sponsoringOrg.id); this.platformUtilsService.showToast("success", null, this.i18nService.t("emailSent")); - this.resendEmailPromise = null; } get isSentAwaitingSync() { @@ -106,31 +98,31 @@ export class SponsoringOrgRowComponent implements OnInit { "revokeWhenExpired", formatDate(validUntil, "MM/dd/yyyy", this.locale), ); - this.statusClass = "text-danger"; + this.statusClass = "tw-text-danger"; } else if (toDelete) { // They want to delete and we don't have a valid until date so we can // this should only happen on a self-hosted install this.statusMessage = this.i18nService.t("requestRemoved"); - this.statusClass = "text-danger"; + this.statusClass = "tw-text-danger"; } else if (validUntil) { // They don't want to delete and they have a valid until date // that means they are actively sponsoring someone this.statusMessage = this.i18nService.t("active"); - this.statusClass = "text-success"; + this.statusClass = "tw-text-success"; } else if (selfHosted && lastSyncDate) { // We are on a self-hosted install and it has been synced but we have not gotten // a valid until date so we can't know if they are actively sponsoring someone this.statusMessage = this.i18nService.t("sent"); - this.statusClass = "text-success"; + this.statusClass = "tw-text-success"; } else if (!selfHosted) { // We are in cloud and all other status checks have been false therefore we have // sent the request but it hasn't been accepted yet this.statusMessage = this.i18nService.t("sent"); - this.statusClass = "text-success"; + this.statusClass = "tw-text-success"; } else { // We are on a self-hosted install and we have not synced yet this.statusMessage = this.i18nService.t("requested"); - this.statusClass = "text-success"; + this.statusClass = "tw-text-success"; } } } diff --git a/apps/web/src/app/auth/settings/account/account.component.html b/apps/web/src/app/auth/settings/account/account.component.html index deb2f0ef301..a5210611941 100644 --- a/apps/web/src/app/auth/settings/account/account.component.html +++ b/apps/web/src/app/auth/settings/account/account.component.html @@ -12,7 +12,7 @@ -
- + diff --git a/apps/web/src/app/auth/settings/account/profile.component.ts b/apps/web/src/app/auth/settings/account/profile.component.ts index 1b7d7f378a1..64c5687c0b8 100644 --- a/apps/web/src/app/auth/settings/account/profile.component.ts +++ b/apps/web/src/app/auth/settings/account/profile.component.ts @@ -1,4 +1,5 @@ import { ViewChild, ViewContainerRef, Component, OnDestroy, OnInit } from "@angular/core"; +import { FormControl, FormGroup } from "@angular/forms"; import { Subject, takeUntil } from "rxjs"; import { ModalService } from "@bitwarden/angular/services/modal.service"; @@ -6,7 +7,6 @@ import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { UpdateProfileRequest } from "@bitwarden/common/auth/models/request/update-profile.request"; import { ProfileResponse } from "@bitwarden/common/models/response/profile.response"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; @@ -21,16 +21,19 @@ export class ProfileComponent implements OnInit, OnDestroy { profile: ProfileResponse; fingerprintMaterial: string; - formPromise: Promise; @ViewChild("avatarModalTemplate", { read: ViewContainerRef, static: true }) avatarModalRef: ViewContainerRef; private destroy$ = new Subject(); + protected formGroup = new FormGroup({ + name: new FormControl(null), + email: new FormControl(null), + }); + constructor( private apiService: ApiService, private i18nService: I18nService, private platformUtilsService: PlatformUtilsService, - private logService: LogService, private stateService: StateService, private modalService: ModalService, ) {} @@ -39,6 +42,15 @@ export class ProfileComponent implements OnInit, OnDestroy { this.profile = await this.apiService.getProfile(); this.loading = false; this.fingerprintMaterial = await this.stateService.getUserId(); + this.formGroup.get("name").setValue(this.profile.name); + this.formGroup.get("email").setValue(this.profile.email); + + this.formGroup + .get("name") + .valueChanges.pipe(takeUntil(this.destroy$)) + .subscribe((name) => { + this.profile.name = name; + }); } async ngOnDestroy() { @@ -46,7 +58,7 @@ export class ProfileComponent implements OnInit, OnDestroy { this.destroy$.complete(); } - async openChangeAvatar() { + openChangeAvatar = async () => { const modalOpened = await this.modalService.openViewRef( ChangeAvatarComponent, this.avatarModalRef, @@ -57,16 +69,14 @@ export class ProfileComponent implements OnInit, OnDestroy { }); }, ); - } + }; - async submit() { - try { - const request = new UpdateProfileRequest(this.profile.name, this.profile.masterPasswordHint); - this.formPromise = this.apiService.putProfile(request); - await this.formPromise; - this.platformUtilsService.showToast("success", null, this.i18nService.t("accountUpdated")); - } catch (e) { - this.logService.error(e); - } - } + submit = async () => { + const request = new UpdateProfileRequest( + this.formGroup.get("name").value, + this.profile.masterPasswordHint, + ); + await this.apiService.putProfile(request); + this.platformUtilsService.showToast("success", null, this.i18nService.t("accountUpdated")); + }; } diff --git a/apps/web/src/app/auth/settings/verify-email.component.html b/apps/web/src/app/auth/settings/verify-email.component.html index ccad78348ca..60f7e52b9a2 100644 --- a/apps/web/src/app/auth/settings/verify-email.component.html +++ b/apps/web/src/app/auth/settings/verify-email.component.html @@ -1,11 +1,14 @@ -
-
- {{ "verifyEmail" | i18n }} -
-
-

{{ "verifyEmailDesc" | i18n }}

- -
-
+ + {{ "verifyEmailDesc" | i18n }} + + diff --git a/apps/web/src/app/auth/settings/verify-email.component.ts b/apps/web/src/app/auth/settings/verify-email.component.ts index d9d2acb87b7..e8809cd8931 100644 --- a/apps/web/src/app/auth/settings/verify-email.component.ts +++ b/apps/web/src/app/auth/settings/verify-email.component.ts @@ -1,25 +1,29 @@ +import { CommonModule } from "@angular/common"; import { Component, EventEmitter, Output } from "@angular/core"; +import { JslibModule } from "@bitwarden/angular/jslib.module"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { AsyncActionsModule, BannerModule, ButtonModule, LinkModule } from "@bitwarden/components"; @Component({ + standalone: true, selector: "app-verify-email", templateUrl: "verify-email.component.html", + imports: [AsyncActionsModule, BannerModule, ButtonModule, CommonModule, JslibModule, LinkModule], }) export class VerifyEmailComponent { actionPromise: Promise; @Output() onVerified = new EventEmitter(); + @Output() onDismiss = new EventEmitter(); constructor( private apiService: ApiService, private i18nService: I18nService, private platformUtilsService: PlatformUtilsService, - private logService: LogService, private tokenService: TokenService, ) {} diff --git a/apps/web/src/app/billing/organizations/organization-plans.component.html b/apps/web/src/app/billing/organizations/organization-plans.component.html index bc46c147909..1bd6b99dd17 100644 --- a/apps/web/src/app/billing/organizations/organization-plans.component.html +++ b/apps/web/src/app/billing/organizations/organization-plans.component.html @@ -51,8 +51,8 @@

{{ "chooseYourPlan" | i18n }}

-
- + +
{{ selectableProduct.nameLocalizationKey | i18n }} - + @@ -176,6 +176,7 @@ !selectableProduct.PasswordManager.basePrice && selectableProduct.PasswordManager.hasAdditionalSeatsOption " + class="tw-pl-4" > {{ "costPerUser" @@ -188,11 +189,11 @@ }} /{{ "month" | i18n }} - {{ + {{ "freeForever" | i18n }} - -
+
+
- +

{{ "summary" | i18n }}

- - {{ (selectablePlan.isAnnual ? "annually" : "monthly") | i18n }} - -

- {{ "basePrice" | i18n }}: - {{ - (selectablePlan.isAnnual - ? selectablePlan.PasswordManager.basePrice / 12 - : selectablePlan.PasswordManager.basePrice - ) | currency: "$" - }} - × 12 - {{ "monthAbbr" | i18n }} - = - - {{ - selectablePlan.PasswordManager.basePrice | currency: "$" - }} - {{ "freeWithSponsorship" | i18n }} - - - {{ selectablePlan.PasswordManager.basePrice | currency: "$" }} +

+ + {{ (selectablePlan.isAnnual ? "annually" : "monthly") | i18n }} + +

+ {{ "basePrice" | i18n }}: + {{ + (selectablePlan.isAnnual + ? selectablePlan.PasswordManager.basePrice / 12 + : selectablePlan.PasswordManager.basePrice + ) | currency: "$" + }} + × 12 + {{ "monthAbbr" | i18n }} + = + + {{ + selectablePlan.PasswordManager.basePrice | currency: "$" + }} + {{ "freeWithSponsorship" | i18n }} + + + {{ selectablePlan.PasswordManager.basePrice | currency: "$" }} + /{{ "year" | i18n }} + +

+

+ {{ "additionalUsers" | i18n }}: + {{ "users" | i18n }}: + {{ formGroup.controls["additionalSeats"].value || 0 }} × + {{ + (selectablePlan.isAnnual + ? selectablePlan.PasswordManager.seatPrice / 12 + : selectablePlan.PasswordManager.seatPrice + ) | currency: "$" + }} + × 12 {{ "monthAbbr" | i18n }} = + {{ + passwordManagerSeatTotal(selectablePlan, formGroup.value.additionalSeats) + | currency: "$" + }} /{{ "year" | i18n }} - -

-

- {{ "additionalUsers" | i18n }}: +

- {{ "users" | i18n }}: - {{ formGroup.controls["additionalSeats"].value || 0 }} × - {{ - (selectablePlan.isAnnual - ? selectablePlan.PasswordManager.seatPrice / 12 - : selectablePlan.PasswordManager.seatPrice - ) | currency: "$" - }} - × 12 {{ "monthAbbr" | i18n }} = - {{ - passwordManagerSeatTotal(selectablePlan, formGroup.value.additionalSeats) - | currency: "$" - }} - /{{ "year" | i18n }} -

-

- {{ "additionalStorageGb" | i18n }}: - {{ formGroup.controls["additionalStorage"].value || 0 }} × - {{ - (selectablePlan.isAnnual - ? selectablePlan.PasswordManager.additionalStoragePricePerGb / 12 - : selectablePlan.PasswordManager.additionalStoragePricePerGb - ) | currency: "$" - }} - × 12 {{ "monthAbbr" | i18n }} = - {{ additionalStorageTotal(selectablePlan) | currency: "$" }} /{{ "year" | i18n }} -

-
- -

- {{ "basePrice" | i18n }}: - {{ selectablePlan.PasswordManager.basePrice | currency: "$" }} - {{ "monthAbbr" | i18n }} - = - {{ selectablePlan.PasswordManager.basePrice | currency: "$" }} - /{{ "month" | i18n }} -

-

- {{ "additionalUsers" | i18n }}: + + +

- {{ "users" | i18n }}: - {{ formGroup.controls["additionalSeats"].value || 0 }} × - {{ selectablePlan.PasswordManager.seatPrice | currency: "$" }} - {{ "monthAbbr" | i18n }} = - {{ - passwordManagerSeatTotal(selectablePlan, formGroup.value.additionalSeats) - | currency: "$" - }} - /{{ "month" | i18n }} -

-

- {{ "additionalStorageGb" | i18n }}: - {{ formGroup.controls["additionalStorage"].value || 0 }} × - {{ selectablePlan.PasswordManager.additionalStoragePricePerGb | currency: "$" }} - {{ "monthAbbr" | i18n }} = - {{ additionalStorageTotal(selectablePlan) | currency: "$" }} /{{ "month" | i18n }} -

-
-
+ {{ "basePrice" | i18n }}: + {{ selectablePlan.PasswordManager.basePrice | currency: "$" }} + {{ "monthAbbr" | i18n }} + = + {{ selectablePlan.PasswordManager.basePrice | currency: "$" }} + /{{ "month" | i18n }} +

+

+ {{ "additionalUsers" | i18n }}: + {{ "users" | i18n }}: + {{ formGroup.controls["additionalSeats"].value || 0 }} × + {{ selectablePlan.PasswordManager.seatPrice | currency: "$" }} + {{ "monthAbbr" | i18n }} = + {{ + passwordManagerSeatTotal(selectablePlan, formGroup.value.additionalSeats) + | currency: "$" + }} + /{{ "month" | i18n }} +

+

+ {{ "additionalStorageGb" | i18n }}: + {{ formGroup.controls["additionalStorage"].value || 0 }} × + {{ selectablePlan.PasswordManager.additionalStoragePricePerGb | currency: "$" }} + {{ "monthAbbr" | i18n }} = + {{ additionalStorageTotal(selectablePlan) | currency: "$" }} /{{ "month" | i18n }} +

+ + +
diff --git a/apps/web/src/app/billing/shared/add-credit-dialog.component.html b/apps/web/src/app/billing/shared/add-credit-dialog.component.html new file mode 100644 index 00000000000..790099df882 --- /dev/null +++ b/apps/web/src/app/billing/shared/add-credit-dialog.component.html @@ -0,0 +1,61 @@ +
+ + +

{{ "creditDelayed" | i18n }}

+
+ + + PayPal + + + Bitcoin + + +
+
+ + {{ "amount" | i18n }} + + $USD + +
+
+ + + + +
+
+
+ + + + + + + + + + + + + + + +
diff --git a/apps/web/src/app/billing/shared/add-credit.component.ts b/apps/web/src/app/billing/shared/add-credit-dialog.component.ts similarity index 64% rename from apps/web/src/app/billing/shared/add-credit.component.ts rename to apps/web/src/app/billing/shared/add-credit-dialog.component.ts index 7cf9054198c..68f074076d7 100644 --- a/apps/web/src/app/billing/shared/add-credit.component.ts +++ b/apps/web/src/app/billing/shared/add-credit-dialog.component.ts @@ -1,12 +1,6 @@ -import { - Component, - ElementRef, - EventEmitter, - Input, - OnInit, - Output, - ViewChild, -} from "@angular/core"; +import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog"; +import { Component, ElementRef, Inject, OnInit, ViewChild } from "@angular/core"; +import { FormControl, FormGroup, Validators } from "@angular/forms"; import { firstValueFrom, map } from "rxjs"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; @@ -17,6 +11,16 @@ import { BitPayInvoiceRequest } from "@bitwarden/common/billing/models/request/b import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { DialogService } from "@bitwarden/components"; + +export interface AddCreditDialogData { + organizationId: string; +} + +export enum AddCreditDialogResult { + Added = "added", + Cancelled = "cancelled", +} export type PayPalConfig = { businessId?: string; @@ -24,17 +28,9 @@ export type PayPalConfig = { }; @Component({ - selector: "app-add-credit", - templateUrl: "add-credit.component.html", + templateUrl: "add-credit-dialog.component.html", }) -export class AddCreditComponent implements OnInit { - @Input() creditAmount: string; - @Input() showOptions = true; - @Input() method = PaymentMethodType.PayPal; - @Input() organizationId: string; - @Output() onAdded = new EventEmitter(); - @Output() onCanceled = new EventEmitter(); - +export class AddCreditDialogComponent implements OnInit { @ViewChild("ppButtonForm", { read: ElementRef, static: true }) ppButtonFormRef: ElementRef; paymentMethodType = PaymentMethodType; @@ -44,14 +40,22 @@ export class AddCreditComponent implements OnInit { ppLoading = false; subject: string; returnUrl: string; - formPromise: Promise; + organizationId: string; private userId: string; private name: string; private email: string; private region: string; + protected DialogResult = AddCreditDialogResult; + protected formGroup = new FormGroup({ + method: new FormControl(PaymentMethodType.PayPal), + creditAmount: new FormControl(null, [Validators.required]), + }); + constructor( + private dialogRef: DialogRef, + @Inject(DIALOG_DATA) protected data: AddCreditDialogData, private accountService: AccountService, private apiService: ApiService, private platformUtilsService: PlatformUtilsService, @@ -59,6 +63,7 @@ export class AddCreditComponent implements OnInit { private logService: LogService, private configService: ConfigService, ) { + this.organizationId = data.organizationId; const payPalConfig = process.env.PAYPAL_CONFIG as PayPalConfig; this.ppButtonFormAction = payPalConfig.buttonAction; this.ppButtonBusinessId = payPalConfig.businessId; @@ -93,7 +98,18 @@ export class AddCreditComponent implements OnInit { this.returnUrl = window.location.href; } - async submit() { + get creditAmount() { + return this.formGroup.value.creditAmount; + } + set creditAmount(value: string) { + this.formGroup.get("creditAmount").setValue(value); + } + + get method() { + return this.formGroup.value.method; + } + + submit = async () => { if (this.creditAmount == null || this.creditAmount === "") { return; } @@ -104,33 +120,20 @@ export class AddCreditComponent implements OnInit { return; } if (this.method === PaymentMethodType.BitPay) { - try { - const req = new BitPayInvoiceRequest(); - req.email = this.email; - req.name = this.name; - req.credit = true; - req.amount = this.creditAmountNumber; - req.organizationId = this.organizationId; - req.userId = this.userId; - req.returnUrl = this.returnUrl; - this.formPromise = this.apiService.postBitPayInvoice(req); - const bitPayUrl: string = await this.formPromise; - this.platformUtilsService.launchUri(bitPayUrl); - } catch (e) { - this.logService.error(e); - } + const req = new BitPayInvoiceRequest(); + req.email = this.email; + req.name = this.name; + req.credit = true; + req.amount = this.creditAmountNumber; + req.organizationId = this.organizationId; + req.userId = this.userId; + req.returnUrl = this.returnUrl; + const bitPayUrl: string = await this.apiService.postBitPayInvoice(req); + this.platformUtilsService.launchUri(bitPayUrl); return; } - try { - this.onAdded.emit(); - } catch (e) { - this.logService.error(e); - } - } - - cancel() { - this.onCanceled.emit(); - } + this.dialogRef.close(AddCreditDialogResult.Added); + }; formatAmount() { try { @@ -160,3 +163,15 @@ export class AddCreditComponent implements OnInit { return null; } } + +/** + * Strongly typed helper to open a AddCreditDialog + * @param dialogService Instance of the dialog service that will be used to open the dialog + * @param config Configuration for the dialog + */ +export function openAddCreditDialog( + dialogService: DialogService, + config: DialogConfig, +) { + return dialogService.open(AddCreditDialogComponent, config); +} diff --git a/apps/web/src/app/billing/shared/add-credit.component.html b/apps/web/src/app/billing/shared/add-credit.component.html deleted file mode 100644 index 73c71812be6..00000000000 --- a/apps/web/src/app/billing/shared/add-credit.component.html +++ /dev/null @@ -1,80 +0,0 @@ -
-
- -

{{ "addCredit" | i18n }}

-
-
- - -
-
- - -
-
-
-
-
- -
-
$USD
- -
-
-
- {{ "creditDelayed" | i18n }} -
- - -
-
-
- - - - - - - - - - - - - - - -
diff --git a/apps/web/src/app/billing/shared/billing-shared.module.ts b/apps/web/src/app/billing/shared/billing-shared.module.ts index 65a651b73df..35fe33c7e06 100644 --- a/apps/web/src/app/billing/shared/billing-shared.module.ts +++ b/apps/web/src/app/billing/shared/billing-shared.module.ts @@ -3,7 +3,7 @@ import { NgModule } from "@angular/core"; import { HeaderModule } from "../../layouts/header/header.module"; import { SharedModule } from "../../shared"; -import { AddCreditComponent } from "./add-credit.component"; +import { AddCreditDialogComponent } from "./add-credit-dialog.component"; import { AdjustPaymentDialogComponent } from "./adjust-payment-dialog.component"; import { AdjustStorageComponent } from "./adjust-storage.component"; import { BillingHistoryComponent } from "./billing-history.component"; @@ -17,7 +17,7 @@ import { UpdateLicenseComponent } from "./update-license.component"; @NgModule({ imports: [SharedModule, PaymentComponent, TaxInfoComponent, HeaderModule], declarations: [ - AddCreditComponent, + AddCreditDialogComponent, AdjustPaymentDialogComponent, AdjustStorageComponent, BillingHistoryComponent, diff --git a/apps/web/src/app/billing/shared/payment-method.component.html b/apps/web/src/app/billing/shared/payment-method.component.html index 5f78294fa64..2ac9233b5bb 100644 --- a/apps/web/src/app/billing/shared/payment-method.component.html +++ b/apps/web/src/app/billing/shared/payment-method.component.html @@ -33,22 +33,9 @@ {{ creditOrBalance | currency: "$" }}

{{ "creditAppliedDesc" | i18n }}

- - -

{{ "paymentMethod" | i18n }}

{{ "noPaymentMethod" | i18n }}

diff --git a/apps/web/src/app/billing/shared/payment-method.component.ts b/apps/web/src/app/billing/shared/payment-method.component.ts index fee97cb912a..967bff6d1ab 100644 --- a/apps/web/src/app/billing/shared/payment-method.component.ts +++ b/apps/web/src/app/billing/shared/payment-method.component.ts @@ -15,6 +15,7 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service" import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { DialogService } from "@bitwarden/components"; +import { AddCreditDialogResult, openAddCreditDialog } from "./add-credit-dialog.component"; import { AdjustPaymentDialogResult, openAdjustPaymentDialog, @@ -30,7 +31,6 @@ export class PaymentMethodComponent implements OnInit { loading = false; firstLoaded = false; - showAddCredit = false; billing: BillingPaymentResponse; org: OrganizationSubscriptionResponse; sub: SubscriptionResponse; @@ -111,18 +111,17 @@ export class PaymentMethodComponent implements OnInit { this.loading = false; } - addCredit() { - this.showAddCredit = true; - } - - closeAddCredit(load: boolean) { - this.showAddCredit = false; - if (load) { - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.load(); + addCredit = async () => { + const dialogRef = openAddCreditDialog(this.dialogService, { + data: { + organizationId: this.organizationId, + }, + }); + const result = await lastValueFrom(dialogRef.closed); + if (result === AddCreditDialogResult.Added) { + await this.load(); } - } + }; changePayment = async () => { const dialogRef = openAdjustPaymentDialog(this.dialogService, { diff --git a/apps/web/src/app/settings/low-kdf.component.html b/apps/web/src/app/settings/low-kdf.component.html deleted file mode 100644 index fd191b21e86..00000000000 --- a/apps/web/src/app/settings/low-kdf.component.html +++ /dev/null @@ -1,17 +0,0 @@ -
diff --git a/apps/web/src/app/settings/low-kdf.component.ts b/apps/web/src/app/settings/low-kdf.component.ts deleted file mode 100644 index a411c1402f2..00000000000 --- a/apps/web/src/app/settings/low-kdf.component.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { Component } from "@angular/core"; - -@Component({ - selector: "app-low-kdf", - templateUrl: "low-kdf.component.html", -}) -export class LowKdfComponent {} diff --git a/apps/web/src/app/shared/loose-components.module.ts b/apps/web/src/app/shared/loose-components.module.ts index b511f5d7660..c7ae63f25c4 100644 --- a/apps/web/src/app/shared/loose-components.module.ts +++ b/apps/web/src/app/shared/loose-components.module.ts @@ -53,7 +53,6 @@ import { TwoFactorSetupComponent } from "../auth/settings/two-factor-setup.compo import { TwoFactorVerifyComponent } from "../auth/settings/two-factor-verify.component"; import { TwoFactorWebAuthnComponent } from "../auth/settings/two-factor-webauthn.component"; import { TwoFactorYubiKeyComponent } from "../auth/settings/two-factor-yubikey.component"; -import { VerifyEmailComponent } from "../auth/settings/verify-email.component"; import { UserVerificationModule } from "../auth/shared/components/user-verification"; import { SsoComponent } from "../auth/sso.component"; import { TwoFactorOptionsComponent } from "../auth/two-factor-options.component"; @@ -70,7 +69,6 @@ import { HeaderModule } from "../layouts/header/header.module"; import { ProductSwitcherModule } from "../layouts/product-switcher/product-switcher.module"; import { UserLayoutComponent } from "../layouts/user-layout.component"; import { DomainRulesComponent } from "../settings/domain-rules.component"; -import { LowKdfComponent } from "../settings/low-kdf.component"; import { PreferencesComponent } from "../settings/preferences.component"; import { VaultTimeoutInputComponent } from "../settings/vault-timeout-input.component"; import { GeneratorComponent } from "../tools/generator.component"; @@ -186,11 +184,9 @@ import { SharedModule } from "./shared.module"; UpdatePasswordComponent, UpdateTempPasswordComponent, VaultTimeoutInputComponent, - VerifyEmailComponent, VerifyEmailTokenComponent, VerifyRecoverDeleteComponent, VerifyRecoverDeleteProviderComponent, - LowKdfComponent, ], exports: [ UserVerificationModule, @@ -264,11 +260,9 @@ import { SharedModule } from "./shared.module"; UpdateTempPasswordComponent, UserLayoutComponent, VaultTimeoutInputComponent, - VerifyEmailComponent, VerifyEmailTokenComponent, VerifyRecoverDeleteComponent, VerifyRecoverDeleteProviderComponent, - LowKdfComponent, HeaderModule, DangerZoneComponent, ], diff --git a/apps/web/src/app/tools/generator.component.ts b/apps/web/src/app/tools/generator.component.ts index 0ddf3064b97..fc27e658465 100644 --- a/apps/web/src/app/tools/generator.component.ts +++ b/apps/web/src/app/tools/generator.component.ts @@ -1,4 +1,4 @@ -import { Component } from "@angular/core"; +import { Component, NgZone } from "@angular/core"; import { ActivatedRoute } from "@angular/router"; import { GeneratorComponent as BaseGeneratorComponent } from "@bitwarden/angular/tools/generator/components/generator.component"; @@ -6,7 +6,6 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { PasswordGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/password"; import { UsernameGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/username"; import { DialogService } from "@bitwarden/components"; @@ -21,23 +20,23 @@ export class GeneratorComponent extends BaseGeneratorComponent { constructor( passwordGenerationService: PasswordGenerationServiceAbstraction, usernameGenerationService: UsernameGenerationServiceAbstraction, - stateService: StateService, + accountService: AccountService, platformUtilsService: PlatformUtilsService, i18nService: I18nService, logService: LogService, route: ActivatedRoute, + ngZone: NgZone, private dialogService: DialogService, - accountService: AccountService, ) { super( passwordGenerationService, usernameGenerationService, platformUtilsService, - stateService, + accountService, i18nService, logService, route, - accountService, + ngZone, window, ); if (platformUtilsService.isSelfHost()) { diff --git a/apps/web/src/app/tools/vault-export/export.component.ts b/apps/web/src/app/tools/vault-export/export.component.ts index 4fdd3ff9e08..7902d2818d6 100644 --- a/apps/web/src/app/tools/vault-export/export.component.ts +++ b/apps/web/src/app/tools/vault-export/export.component.ts @@ -1,11 +1,9 @@ import { Component } from "@angular/core"; import { UntypedFormBuilder } from "@angular/forms"; -import { UserVerificationDialogComponent } from "@bitwarden/auth/angular"; import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; -import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -26,7 +24,6 @@ export class ExportComponent extends BaseExportComponent { eventCollectionService: EventCollectionService, policyService: PolicyService, logService: LogService, - userVerificationService: UserVerificationService, formBuilder: UntypedFormBuilder, fileDownloadService: FileDownloadService, dialogService: DialogService, @@ -39,84 +36,10 @@ export class ExportComponent extends BaseExportComponent { eventCollectionService, policyService, logService, - userVerificationService, formBuilder, fileDownloadService, dialogService, organizationService, ); } - - submit = async () => { - if (this.isFileEncryptedExport && this.filePassword != this.confirmFilePassword) { - this.platformUtilsService.showToast( - "error", - this.i18nService.t("errorOccurred"), - this.i18nService.t("filePasswordAndConfirmFilePasswordDoNotMatch"), - ); - return; - } - - this.exportForm.markAllAsTouched(); - if (this.exportForm.invalid) { - return; - } - - if (this.disabledByPolicy) { - this.platformUtilsService.showToast( - "error", - null, - this.i18nService.t("personalVaultExportPolicyInEffect"), - ); - return; - } - - const userVerified = await this.verifyUser(); - if (!userVerified) { - return; - } - - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.doExport(); - }; - - protected saved() { - super.saved(); - this.platformUtilsService.showToast("success", null, this.i18nService.t("exportSuccess")); - } - - private async verifyUser(): Promise { - let confirmDescription = "exportWarningDesc"; - if (this.isFileEncryptedExport) { - confirmDescription = "fileEncryptedExportWarningDesc"; - } else if (this.isAccountEncryptedExport) { - confirmDescription = "encExportKeyWarningDesc"; - } - - const result = await UserVerificationDialogComponent.open(this.dialogService, { - title: "confirmVaultExport", - bodyText: confirmDescription, - confirmButtonOptions: { - text: "exportVault", - type: "primary", - }, - }); - - // Handle the result of the dialog based on user action and verification success - if (result.userAction === "cancel") { - // User cancelled the dialog - return false; - } - - // User confirmed the dialog so check verification success - if (!result.verificationSuccess) { - if (result.noAvailableClientVerificationMethods) { - // No client-side verification methods are available - // Could send user to configure a verification method like PIN or biometrics - } - return false; - } - return true; - } } diff --git a/apps/web/src/app/vault/core/views/collection-admin.view.ts b/apps/web/src/app/vault/core/views/collection-admin.view.ts index 96a8150b0a4..f8695ba2171 100644 --- a/apps/web/src/app/vault/core/views/collection-admin.view.ts +++ b/apps/web/src/app/vault/core/views/collection-admin.view.ts @@ -85,14 +85,26 @@ export class CollectionAdminView extends CollectionView { * Whether the user can modify user access to this collection */ canEditUserAccess(org: Organization, flexibleCollectionsV1Enabled: boolean): boolean { - return this.canEdit(org, flexibleCollectionsV1Enabled) || org.permissions.manageUsers; + const allowAdminAccessToAllCollectionItems = + !flexibleCollectionsV1Enabled || org.allowAdminAccessToAllCollectionItems; + + return ( + (org.permissions.manageUsers && allowAdminAccessToAllCollectionItems) || + this.canEdit(org, flexibleCollectionsV1Enabled) + ); } /** * Whether the user can modify group access to this collection */ canEditGroupAccess(org: Organization, flexibleCollectionsV1Enabled: boolean): boolean { - return this.canEdit(org, flexibleCollectionsV1Enabled) || org.permissions.manageGroups; + const allowAdminAccessToAllCollectionItems = + !flexibleCollectionsV1Enabled || org.allowAdminAccessToAllCollectionItems; + + return ( + (org.permissions.manageGroups && allowAdminAccessToAllCollectionItems) || + this.canEdit(org, flexibleCollectionsV1Enabled) + ); } /** diff --git a/apps/web/src/app/vault/individual-vault/vault-banners/services/vault-banners.service.spec.ts b/apps/web/src/app/vault/individual-vault/vault-banners/services/vault-banners.service.spec.ts new file mode 100644 index 00000000000..c09451addda --- /dev/null +++ b/apps/web/src/app/vault/individual-vault/vault-banners/services/vault-banners.service.spec.ts @@ -0,0 +1,265 @@ +import { TestBed } from "@angular/core/testing"; +import { BehaviorSubject, firstValueFrom } from "rxjs"; + +import { KdfConfigService } from "@bitwarden/common/auth/abstractions/kdf-config.service"; +import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; +import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { KdfType } from "@bitwarden/common/platform/enums"; +import { StateProvider } from "@bitwarden/common/platform/state"; +import { FakeStateProvider, mockAccountServiceWith } from "@bitwarden/common/spec"; +import { UserId } from "@bitwarden/common/types/guid"; +import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; + +import { + PREMIUM_BANNER_REPROMPT_KEY, + VaultBannersService, + VisibleVaultBanner, +} from "./vault-banners.service"; + +describe("VaultBannersService", () => { + let service: VaultBannersService; + const isSelfHost = jest.fn().mockReturnValue(false); + const hasPremiumFromAnySource$ = new BehaviorSubject(false); + const fakeStateProvider = new FakeStateProvider(mockAccountServiceWith("user-id" as UserId)); + const getEmailVerified = jest.fn().mockResolvedValue(true); + const hasMasterPassword = jest.fn().mockResolvedValue(true); + const getKdfConfig = jest + .fn() + .mockResolvedValue({ kdfType: KdfType.PBKDF2_SHA256, iterations: 600000 }); + const getLastSync = jest.fn().mockResolvedValue(null); + + beforeEach(() => { + jest.useFakeTimers(); + getLastSync.mockClear().mockResolvedValue(new Date("2024-05-14")); + isSelfHost.mockClear(); + getEmailVerified.mockClear().mockResolvedValue(true); + + TestBed.configureTestingModule({ + providers: [ + VaultBannersService, + { + provide: PlatformUtilsService, + useValue: { isSelfHost }, + }, + { + provide: BillingAccountProfileStateService, + useValue: { hasPremiumFromAnySource$: hasPremiumFromAnySource$ }, + }, + { + provide: StateProvider, + useValue: fakeStateProvider, + }, + { + provide: PlatformUtilsService, + useValue: { isSelfHost }, + }, + { + provide: TokenService, + useValue: { getEmailVerified }, + }, + { + provide: UserVerificationService, + useValue: { hasMasterPassword }, + }, + { + provide: KdfConfigService, + useValue: { getKdfConfig }, + }, + { + provide: SyncService, + useValue: { getLastSync }, + }, + ], + }); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + describe("Premium", () => { + it("waits until sync is completed before showing premium banner", async () => { + getLastSync.mockResolvedValue(new Date("2024-05-14")); + hasPremiumFromAnySource$.next(false); + isSelfHost.mockReturnValue(false); + + service = TestBed.inject(VaultBannersService); + + jest.advanceTimersByTime(201); + + expect(await firstValueFrom(service.shouldShowPremiumBanner$)).toBe(true); + }); + + it("does not show a premium banner for self-hosted users", async () => { + getLastSync.mockResolvedValue(new Date("2024-05-14")); + hasPremiumFromAnySource$.next(false); + isSelfHost.mockReturnValue(true); + + service = TestBed.inject(VaultBannersService); + + jest.advanceTimersByTime(201); + + expect(await firstValueFrom(service.shouldShowPremiumBanner$)).toBe(false); + }); + + it("does not show a premium banner when they have access to premium", async () => { + getLastSync.mockResolvedValue(new Date("2024-05-14")); + hasPremiumFromAnySource$.next(true); + isSelfHost.mockReturnValue(false); + + service = TestBed.inject(VaultBannersService); + + jest.advanceTimersByTime(201); + + expect(await firstValueFrom(service.shouldShowPremiumBanner$)).toBe(false); + }); + + describe("dismissing", () => { + beforeEach(async () => { + jest.useFakeTimers(); + const date = new Date("2023-06-08"); + date.setHours(0, 0, 0, 0); + jest.setSystemTime(date.getTime()); + + service = TestBed.inject(VaultBannersService); + await service.dismissBanner(VisibleVaultBanner.Premium); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it("updates state on first dismiss", async () => { + const state = await firstValueFrom( + fakeStateProvider.getActive(PREMIUM_BANNER_REPROMPT_KEY).state$, + ); + + const oneWeekLater = new Date("2023-06-15"); + oneWeekLater.setHours(0, 0, 0, 0); + + expect(state).toEqual({ + numberOfDismissals: 1, + nextPromptDate: oneWeekLater.getTime(), + }); + }); + + it("updates state on second dismiss", async () => { + const state = await firstValueFrom( + fakeStateProvider.getActive(PREMIUM_BANNER_REPROMPT_KEY).state$, + ); + + const oneMonthLater = new Date("2023-07-08"); + oneMonthLater.setHours(0, 0, 0, 0); + + expect(state).toEqual({ + numberOfDismissals: 2, + nextPromptDate: oneMonthLater.getTime(), + }); + }); + + it("updates state on third dismiss", async () => { + const state = await firstValueFrom( + fakeStateProvider.getActive(PREMIUM_BANNER_REPROMPT_KEY).state$, + ); + + const oneYearLater = new Date("2024-06-08"); + oneYearLater.setHours(0, 0, 0, 0); + + expect(state).toEqual({ + numberOfDismissals: 3, + nextPromptDate: oneYearLater.getTime(), + }); + }); + }); + }); + + describe("KDFSettings", () => { + beforeEach(async () => { + hasMasterPassword.mockResolvedValue(true); + getKdfConfig.mockResolvedValue({ kdfType: KdfType.PBKDF2_SHA256, iterations: 599999 }); + }); + + it("shows low KDF iteration banner", async () => { + service = TestBed.inject(VaultBannersService); + + expect(await service.shouldShowLowKDFBanner()).toBe(true); + }); + + it("does not show low KDF iteration banner if KDF type is not PBKDF2_SHA256", async () => { + getKdfConfig.mockResolvedValue({ kdfType: KdfType.Argon2id, iterations: 600001 }); + + service = TestBed.inject(VaultBannersService); + + expect(await service.shouldShowLowKDFBanner()).toBe(false); + }); + + it("does not show low KDF for iterations about 600,000", async () => { + getKdfConfig.mockResolvedValue({ kdfType: KdfType.PBKDF2_SHA256, iterations: 600001 }); + + service = TestBed.inject(VaultBannersService); + + expect(await service.shouldShowLowKDFBanner()).toBe(false); + }); + + it("dismisses low KDF iteration banner", async () => { + service = TestBed.inject(VaultBannersService); + + expect(await service.shouldShowLowKDFBanner()).toBe(true); + + await service.dismissBanner(VisibleVaultBanner.KDFSettings); + + expect(await service.shouldShowLowKDFBanner()).toBe(false); + }); + }); + + describe("OutdatedBrowser", () => { + beforeEach(async () => { + // Hardcode `MSIE` in userAgent string + const userAgent = "AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 MSIE"; + Object.defineProperty(navigator, "userAgent", { + configurable: true, + get: () => userAgent, + }); + }); + + it("shows outdated browser banner", async () => { + service = TestBed.inject(VaultBannersService); + + expect(await service.shouldShowUpdateBrowserBanner()).toBe(true); + }); + + it("dismisses outdated browser banner", async () => { + service = TestBed.inject(VaultBannersService); + + expect(await service.shouldShowUpdateBrowserBanner()).toBe(true); + + await service.dismissBanner(VisibleVaultBanner.OutdatedBrowser); + + expect(await service.shouldShowUpdateBrowserBanner()).toBe(false); + }); + }); + + describe("VerifyEmail", () => { + beforeEach(async () => { + getEmailVerified.mockResolvedValue(false); + }); + + it("shows verify email banner", async () => { + service = TestBed.inject(VaultBannersService); + + expect(await service.shouldShowVerifyEmailBanner()).toBe(true); + }); + + it("dismisses verify email banner", async () => { + service = TestBed.inject(VaultBannersService); + + expect(await service.shouldShowVerifyEmailBanner()).toBe(true); + + await service.dismissBanner(VisibleVaultBanner.VerifyEmail); + + expect(await service.shouldShowVerifyEmailBanner()).toBe(false); + }); + }); +}); diff --git a/apps/web/src/app/vault/individual-vault/vault-banners/services/vault-banners.service.ts b/apps/web/src/app/vault/individual-vault/vault-banners/services/vault-banners.service.ts new file mode 100644 index 00000000000..b54f2e9c115 --- /dev/null +++ b/apps/web/src/app/vault/individual-vault/vault-banners/services/vault-banners.service.ts @@ -0,0 +1,215 @@ +import { Injectable } from "@angular/core"; +import { Subject, Observable, combineLatest, firstValueFrom, map } from "rxjs"; +import { mergeMap, take } from "rxjs/operators"; + +import { KdfConfigService } from "@bitwarden/common/auth/abstractions/kdf-config.service"; +import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; +import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { KdfType, PBKDF2_ITERATIONS } from "@bitwarden/common/platform/enums"; +import { + StateProvider, + ActiveUserState, + KeyDefinition, + PREMIUM_BANNER_DISK_LOCAL, + BANNERS_DISMISSED_DISK, +} from "@bitwarden/common/platform/state"; +import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; + +export enum VisibleVaultBanner { + KDFSettings = "kdf-settings", + OutdatedBrowser = "outdated-browser", + Premium = "premium", + VerifyEmail = "verify-email", +} + +type PremiumBannerReprompt = { + numberOfDismissals: number; + /** Timestamp representing when to show the prompt next */ + nextPromptDate: number; +}; + +/** Banners that will be re-shown on a new session */ +type SessionBanners = Omit; + +export const PREMIUM_BANNER_REPROMPT_KEY = new KeyDefinition( + PREMIUM_BANNER_DISK_LOCAL, + "bannerReprompt", + { + deserializer: (bannerReprompt) => bannerReprompt, + }, +); + +export const BANNERS_DISMISSED_DISK_KEY = new KeyDefinition( + BANNERS_DISMISSED_DISK, + "bannersDismissed", + { + deserializer: (bannersDismissed) => bannersDismissed, + }, +); + +@Injectable() +export class VaultBannersService { + shouldShowPremiumBanner$: Observable; + + private premiumBannerState: ActiveUserState; + private sessionBannerState: ActiveUserState; + + /** + * Emits when the sync service has completed a sync + * + * This is needed because `hasPremiumFromAnySource$` will emit false until the sync is completed + * resulting in the premium banner being shown briefly on startup when the user has access to + * premium features. + */ + private syncCompleted$ = new Subject(); + + constructor( + private tokenService: TokenService, + private userVerificationService: UserVerificationService, + private stateProvider: StateProvider, + private billingAccountProfileStateService: BillingAccountProfileStateService, + private platformUtilsService: PlatformUtilsService, + private kdfConfigService: KdfConfigService, + private syncService: SyncService, + ) { + this.pollUntilSynced(); + this.premiumBannerState = this.stateProvider.getActive(PREMIUM_BANNER_REPROMPT_KEY); + this.sessionBannerState = this.stateProvider.getActive(BANNERS_DISMISSED_DISK_KEY); + + const premiumSources$ = combineLatest([ + this.billingAccountProfileStateService.hasPremiumFromAnySource$, + this.premiumBannerState.state$, + ]); + + this.shouldShowPremiumBanner$ = this.syncCompleted$.pipe( + take(1), // Wait until the first sync is complete before considering the premium status + mergeMap(() => premiumSources$), + map(([canAccessPremium, dismissedState]) => { + const shouldShowPremiumBanner = + !canAccessPremium && !this.platformUtilsService.isSelfHost(); + + // Check if nextPromptDate is in the past passed + if (shouldShowPremiumBanner && dismissedState?.nextPromptDate) { + const nextPromptDate = new Date(dismissedState.nextPromptDate); + const now = new Date(); + return now >= nextPromptDate; + } + + return shouldShowPremiumBanner; + }), + ); + } + + /** Returns true when the update browser banner should be shown */ + async shouldShowUpdateBrowserBanner(): Promise { + const outdatedBrowser = window.navigator.userAgent.indexOf("MSIE") !== -1; + const alreadyDismissed = (await this.getBannerDismissedState()).includes( + VisibleVaultBanner.OutdatedBrowser, + ); + + return outdatedBrowser && !alreadyDismissed; + } + + /** Returns true when the verify email banner should be shown */ + async shouldShowVerifyEmailBanner(): Promise { + const needsVerification = !(await this.tokenService.getEmailVerified()); + + const alreadyDismissed = (await this.getBannerDismissedState()).includes( + VisibleVaultBanner.VerifyEmail, + ); + + return needsVerification && !alreadyDismissed; + } + + /** Returns true when the low KDF iteration banner should be shown */ + async shouldShowLowKDFBanner(): Promise { + const hasLowKDF = (await this.userVerificationService.hasMasterPassword()) + ? await this.isLowKdfIteration() + : false; + + const alreadyDismissed = (await this.getBannerDismissedState()).includes( + VisibleVaultBanner.KDFSettings, + ); + + return hasLowKDF && !alreadyDismissed; + } + + /** Dismiss the given banner and perform any respective side effects */ + async dismissBanner(banner: SessionBanners): Promise { + if (banner === VisibleVaultBanner.Premium) { + await this.dismissPremiumBanner(); + } else { + await this.sessionBannerState.update((current) => { + const bannersDismissed = current ?? []; + + return [...bannersDismissed, banner]; + }); + } + } + + /** Returns banners that have already been dismissed */ + private async getBannerDismissedState(): Promise { + // `state$` can emit null when a value has not been set yet, + // use nullish coalescing to default to an empty array + return (await firstValueFrom(this.sessionBannerState.state$)) ?? []; + } + + /** Increment dismissal state of the premium banner */ + private async dismissPremiumBanner(): Promise { + await this.premiumBannerState.update((current) => { + const numberOfDismissals = current?.numberOfDismissals ?? 0; + const now = new Date(); + + // Set midnight of the current day + now.setHours(0, 0, 0, 0); + + // First dismissal, re-prompt in 1 week + if (numberOfDismissals === 0) { + now.setDate(now.getDate() + 7); + return { + numberOfDismissals: 1, + nextPromptDate: now.getTime(), + }; + } + + // Second dismissal, re-prompt in 1 month + if (numberOfDismissals === 1) { + now.setMonth(now.getMonth() + 1); + return { + numberOfDismissals: 2, + nextPromptDate: now.getTime(), + }; + } + + // 3+ dismissals, re-prompt each year + // Avoid day/month edge cases and only increment year + const nextYear = new Date(now.getFullYear() + 1, now.getMonth(), now.getDate()); + nextYear.setHours(0, 0, 0, 0); + return { + numberOfDismissals: numberOfDismissals + 1, + nextPromptDate: nextYear.getTime(), + }; + }); + } + + private async isLowKdfIteration() { + const kdfConfig = await this.kdfConfigService.getKdfConfig(); + return ( + kdfConfig.kdfType === KdfType.PBKDF2_SHA256 && + kdfConfig.iterations < PBKDF2_ITERATIONS.defaultValue + ); + } + + /** Poll the `syncService` until a sync is completed */ + private pollUntilSynced() { + const interval = setInterval(async () => { + const lastSync = await this.syncService.getLastSync(); + if (lastSync !== null) { + clearInterval(interval); + this.syncCompleted$.next(); + } + }, 200); + } +} diff --git a/apps/web/src/app/vault/individual-vault/vault-banners/vault-banners.component.html b/apps/web/src/app/vault/individual-vault/vault-banners/vault-banners.component.html new file mode 100644 index 00000000000..bcbe424b469 --- /dev/null +++ b/apps/web/src/app/vault/individual-vault/vault-banners/vault-banners.component.html @@ -0,0 +1,52 @@ + + {{ "updateBrowserDesc" | i18n }} + + {{ "updateBrowser" | i18n }} + + + + + {{ "lowKDFIterationsBanner" | i18n }} + + {{ "changeKDFSettings" | i18n }} + + + + + + + {{ "premiumUpgradeUnlockFeatures" | i18n }} + + {{ "goPremium" | i18n }} + + diff --git a/apps/web/src/app/vault/individual-vault/vault-banners/vault-banners.component.spec.ts b/apps/web/src/app/vault/individual-vault/vault-banners/vault-banners.component.spec.ts new file mode 100644 index 00000000000..8c637d22b17 --- /dev/null +++ b/apps/web/src/app/vault/individual-vault/vault-banners/vault-banners.component.spec.ts @@ -0,0 +1,140 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { By } from "@angular/platform-browser"; +import { mock } from "jest-mock-extended"; +import { BehaviorSubject } from "rxjs"; + +import { I18nPipe } from "@bitwarden/angular/platform/pipes/i18n.pipe"; +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { BannerComponent, BannerModule } from "@bitwarden/components"; + +import { VerifyEmailComponent } from "../../../auth/settings/verify-email.component"; +import { LooseComponentsModule } from "../../../shared"; + +import { VaultBannersService, VisibleVaultBanner } from "./services/vault-banners.service"; +import { VaultBannersComponent } from "./vault-banners.component"; + +describe("VaultBannersComponent", () => { + let component: VaultBannersComponent; + let fixture: ComponentFixture; + const premiumBanner$ = new BehaviorSubject(false); + + const bannerService = mock({ + shouldShowPremiumBanner$: premiumBanner$, + shouldShowUpdateBrowserBanner: jest.fn(), + shouldShowVerifyEmailBanner: jest.fn(), + shouldShowLowKDFBanner: jest.fn(), + dismissBanner: jest.fn(), + }); + + beforeEach(async () => { + bannerService.shouldShowPremiumBanner$ = premiumBanner$; + bannerService.shouldShowUpdateBrowserBanner.mockResolvedValue(false); + bannerService.shouldShowVerifyEmailBanner.mockResolvedValue(false); + bannerService.shouldShowLowKDFBanner.mockResolvedValue(false); + + await TestBed.configureTestingModule({ + imports: [BannerModule, LooseComponentsModule, VerifyEmailComponent], + declarations: [VaultBannersComponent, I18nPipe], + providers: [ + { + provide: VaultBannersService, + useValue: bannerService, + }, + { + provide: I18nService, + useValue: mock({ t: (key) => key }), + }, + { + provide: ApiService, + useValue: mock(), + }, + { + provide: PlatformUtilsService, + useValue: mock(), + }, + { + provide: TokenService, + useValue: mock(), + }, + ], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(VaultBannersComponent); + component = fixture.componentInstance; + + fixture.detectChanges(); + }); + + describe("premiumBannerVisible$", () => { + it("shows premium banner", async () => { + premiumBanner$.next(true); + + fixture.detectChanges(); + + const banner = fixture.debugElement.query(By.directive(BannerComponent)); + expect(banner.componentInstance.bannerType).toBe("premium"); + }); + + it("dismisses premium banner", async () => { + premiumBanner$.next(false); + + fixture.detectChanges(); + + const banner = fixture.debugElement.query(By.directive(BannerComponent)); + expect(banner).toBeNull(); + }); + }); + + describe("determineVisibleBanner", () => { + [ + { + name: "OutdatedBrowser", + method: bannerService.shouldShowUpdateBrowserBanner, + banner: VisibleVaultBanner.OutdatedBrowser, + }, + { + name: "VerifyEmail", + method: bannerService.shouldShowVerifyEmailBanner, + banner: VisibleVaultBanner.VerifyEmail, + }, + { + name: "LowKDF", + method: bannerService.shouldShowLowKDFBanner, + banner: VisibleVaultBanner.KDFSettings, + }, + ].forEach(({ name, method, banner }) => { + describe(name, () => { + beforeEach(async () => { + method.mockResolvedValue(true); + + await component.ngOnInit(); + fixture.detectChanges(); + }); + + it(`shows ${name} banner`, async () => { + expect(component.visibleBanners).toEqual([banner]); + }); + + it(`dismisses ${name} banner`, async () => { + const dismissButton = fixture.debugElement.nativeElement.querySelector( + 'button[biticonbutton="bwi-close"]', + ); + + // Mock out the banner service returning false after dismissing + method.mockResolvedValue(false); + + dismissButton.dispatchEvent(new Event("click")); + + expect(bannerService.dismissBanner).toHaveBeenCalledWith(banner); + + expect(component.visibleBanners).toEqual([]); + }); + }); + }); + }); +}); diff --git a/apps/web/src/app/vault/individual-vault/vault-banners/vault-banners.component.ts b/apps/web/src/app/vault/individual-vault/vault-banners/vault-banners.component.ts new file mode 100644 index 00000000000..e612bc231da --- /dev/null +++ b/apps/web/src/app/vault/individual-vault/vault-banners/vault-banners.component.ts @@ -0,0 +1,41 @@ +import { Component, OnInit } from "@angular/core"; +import { Observable } from "rxjs"; + +import { VaultBannersService, VisibleVaultBanner } from "./services/vault-banners.service"; + +@Component({ + selector: "app-vault-banners", + templateUrl: "./vault-banners.component.html", +}) +export class VaultBannersComponent implements OnInit { + visibleBanners: VisibleVaultBanner[] = []; + premiumBannerVisible$: Observable; + VisibleVaultBanner = VisibleVaultBanner; + + constructor(private vaultBannerService: VaultBannersService) { + this.premiumBannerVisible$ = this.vaultBannerService.shouldShowPremiumBanner$; + } + + async ngOnInit(): Promise { + await this.determineVisibleBanners(); + } + + async dismissBanner(banner: VisibleVaultBanner): Promise { + await this.vaultBannerService.dismissBanner(banner); + + await this.determineVisibleBanners(); + } + + /** Determine which banners should be present */ + private async determineVisibleBanners(): Promise { + const showBrowserOutdated = await this.vaultBannerService.shouldShowUpdateBrowserBanner(); + const showVerifyEmail = await this.vaultBannerService.shouldShowVerifyEmailBanner(); + const showLowKdf = await this.vaultBannerService.shouldShowLowKDFBanner(); + + this.visibleBanners = [ + showBrowserOutdated ? VisibleVaultBanner.OutdatedBrowser : null, + showVerifyEmail ? VisibleVaultBanner.VerifyEmail : null, + showLowKdf ? VisibleVaultBanner.KDFSettings : null, + ].filter(Boolean); // remove all falsy values, i.e. null + } +} diff --git a/apps/web/src/app/vault/individual-vault/vault.component.html b/apps/web/src/app/vault/individual-vault/vault.component.html index 3f95665f37a..f39ec3378dc 100644 --- a/apps/web/src/app/vault/individual-vault/vault.component.html +++ b/apps/web/src/app/vault/individual-vault/vault.component.html @@ -1,3 +1,5 @@ + + -
-
+
+
@@ -30,7 +32,7 @@
-
+
{{ trashCleanupWarning }} @@ -81,44 +83,6 @@
-
- - - - -
-
- - {{ "updateBrowser" | i18n }} -
-
-

{{ "updateBrowserDesc" | i18n }}

- - {{ "updateBrowser" | i18n }} - -
-
-
-
- {{ "goPremium" | i18n }} -
-
-

{{ "premiumUpgradeUnlockFeatures" | i18n }}

- - {{ "goPremium" | i18n }} - -
-
-
diff --git a/apps/web/src/app/vault/individual-vault/vault.component.ts b/apps/web/src/app/vault/individual-vault/vault.component.ts index 474e9045d0e..ca04b3aa51f 100644 --- a/apps/web/src/app/vault/individual-vault/vault.component.ts +++ b/apps/web/src/app/vault/individual-vault/vault.component.ts @@ -35,9 +35,6 @@ import { EventCollectionService } from "@bitwarden/common/abstractions/event/eve import { SearchService } from "@bitwarden/common/abstractions/search.service"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; -import { KdfConfigService } from "@bitwarden/common/auth/abstractions/kdf-config.service"; -import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; -import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { EventType } from "@bitwarden/common/enums"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; @@ -47,7 +44,6 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { KdfType, PBKDF2_ITERATIONS } from "@bitwarden/common/platform/enums"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CollectionService } from "@bitwarden/common/vault/abstractions/collection.service"; @@ -122,10 +118,6 @@ export class VaultComponent implements OnInit, OnDestroy { @ViewChild("collectionsModal", { read: ViewContainerRef, static: true }) collectionsModalRef: ViewContainerRef; - showVerifyEmail = false; - showBrowserOutdated = false; - showPremiumCallout = false; - showLowKdf = false; trashCleanupWarning: string = null; kdfIterations: number; activeFilter: VaultFilter = new VaultFilter(); @@ -161,7 +153,6 @@ export class VaultComponent implements OnInit, OnDestroy { private i18nService: I18nService, private modalService: ModalService, private dialogService: DialogService, - private tokenService: TokenService, private messagingService: MessagingService, private platformUtilsService: PlatformUtilsService, private broadcasterService: BroadcasterService, @@ -180,14 +171,11 @@ export class VaultComponent implements OnInit, OnDestroy { private searchPipe: SearchPipe, private configService: ConfigService, private apiService: ApiService, - private userVerificationService: UserVerificationService, private billingAccountProfileStateService: BillingAccountProfileStateService, private toastService: ToastService, - protected kdfConfigService: KdfConfigService, ) {} async ngOnInit() { - this.showBrowserOutdated = window.navigator.userAgent.indexOf("MSIE") !== -1; this.trashCleanupWarning = this.i18nService.t( this.platformUtilsService.isSelfHost() ? "trashCleanupWarningSelfHosted" @@ -197,18 +185,8 @@ export class VaultComponent implements OnInit, OnDestroy { const firstSetup$ = this.route.queryParams.pipe( first(), switchMap(async (params: Params) => { - this.showVerifyEmail = !(await this.tokenService.getEmailVerified()); - this.showLowKdf = (await this.userVerificationService.hasMasterPassword()) - ? await this.isLowKdfIteration() - : false; await this.syncService.fullSync(false); - const canAccessPremium = await firstValueFrom( - this.billingAccountProfileStateService.hasPremiumFromAnySource$, - ); - this.showPremiumCallout = - !this.showVerifyEmail && !canAccessPremium && !this.platformUtilsService.isSelfHost(); - const cipherId = getCipherIdFromParams(params); if (!cipherId) { return; @@ -412,16 +390,6 @@ export class VaultComponent implements OnInit, OnDestroy { ); } - get isShowingCards() { - return ( - this.showBrowserOutdated || this.showPremiumCallout || this.showVerifyEmail || this.showLowKdf - ); - } - - emailVerified(verified: boolean) { - this.showVerifyEmail = !verified; - } - ngOnDestroy() { this.broadcasterService.unsubscribe(BroadcasterSubscriptionId); this.destroy$.next(); @@ -1005,14 +973,6 @@ export class VaultComponent implements OnInit, OnDestroy { : this.cipherService.softDeleteWithServer(id); } - async isLowKdfIteration() { - const kdfConfig = await this.kdfConfigService.getKdfConfig(); - return ( - kdfConfig.kdfType === KdfType.PBKDF2_SHA256 && - kdfConfig.iterations < PBKDF2_ITERATIONS.defaultValue - ); - } - protected async repromptCipher(ciphers: CipherView[]) { const notProtected = !ciphers.find((cipher) => cipher.reprompt !== CipherRepromptType.None); diff --git a/apps/web/src/app/vault/individual-vault/vault.module.ts b/apps/web/src/app/vault/individual-vault/vault.module.ts index 81fc38eda11..c79c64c1ebf 100644 --- a/apps/web/src/app/vault/individual-vault/vault.module.ts +++ b/apps/web/src/app/vault/individual-vault/vault.module.ts @@ -1,7 +1,8 @@ import { NgModule } from "@angular/core"; -import { BreadcrumbsModule } from "@bitwarden/components"; +import { BannerModule, BreadcrumbsModule } from "@bitwarden/components"; +import { VerifyEmailComponent } from "../../auth/settings/verify-email.component"; import { LooseComponentsModule, SharedModule } from "../../shared"; import { CollectionDialogModule } from "../components/collection-dialog"; import { VaultItemsModule } from "../components/vault-items/vault-items.module"; @@ -11,6 +12,8 @@ import { GroupBadgeModule } from "../org-vault/group-badge/group-badge.module"; import { BulkDialogsModule } from "./bulk-action-dialogs/bulk-dialogs.module"; import { OrganizationBadgeModule } from "./organization-badge/organization-badge.module"; import { PipesModule } from "./pipes/pipes.module"; +import { VaultBannersService } from "./vault-banners/services/vault-banners.service"; +import { VaultBannersComponent } from "./vault-banners/vault-banners.component"; import { VaultFilterModule } from "./vault-filter/vault-filter.module"; import { VaultHeaderComponent } from "./vault-header/vault-header.component"; import { VaultOnboardingService as VaultOnboardingServiceAbstraction } from "./vault-onboarding/services/abstraction/vault-onboarding.service"; @@ -34,10 +37,13 @@ import { VaultComponent } from "./vault.component"; VaultItemsModule, CollectionDialogModule, VaultOnboardingComponent, + BannerModule, + VerifyEmailComponent, ], - declarations: [VaultComponent, VaultHeaderComponent], + declarations: [VaultComponent, VaultHeaderComponent, VaultBannersComponent], exports: [VaultComponent], providers: [ + VaultBannersService, { provide: VaultOnboardingServiceAbstraction, useClass: VaultOnboardingService, diff --git a/apps/web/src/app/vault/settings/purge-vault.component.html b/apps/web/src/app/vault/settings/purge-vault.component.html index 485e64617e2..407e0a39ad1 100644 --- a/apps/web/src/app/vault/settings/purge-vault.component.html +++ b/apps/web/src/app/vault/settings/purge-vault.component.html @@ -1,38 +1,19 @@ - +
+ + +

+ {{ (organizationId ? "purgeOrgVaultDesc" : "purgeVaultDesc") | i18n }} +

+ {{ "purgeVaultWarning" | i18n }} + +
+ + + + +
+
diff --git a/apps/web/src/app/vault/settings/purge-vault.component.ts b/apps/web/src/app/vault/settings/purge-vault.component.ts index 4ef9e20e2a1..869cbaab1b4 100644 --- a/apps/web/src/app/vault/settings/purge-vault.component.ts +++ b/apps/web/src/app/vault/settings/purge-vault.component.ts @@ -1,55 +1,60 @@ -import { Component, Input } from "@angular/core"; +import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog"; +import { Component, Inject } from "@angular/core"; +import { FormControl, FormGroup } from "@angular/forms"; import { Router } from "@angular/router"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; import { Verification } from "@bitwarden/common/auth/types/verification"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; +import { DialogService } from "@bitwarden/components"; + +export interface PurgeVaultDialogData { + organizationId: string; +} @Component({ selector: "app-purge-vault", templateUrl: "purge-vault.component.html", }) export class PurgeVaultComponent { - @Input() organizationId?: string = null; + organizationId: string = null; - masterPassword: Verification; - formPromise: Promise; + formGroup = new FormGroup({ + masterPassword: new FormControl(null), + }); constructor( + @Inject(DIALOG_DATA) protected data: PurgeVaultDialogData, + private dialogRef: DialogRef, private apiService: ApiService, private i18nService: I18nService, private platformUtilsService: PlatformUtilsService, private userVerificationService: UserVerificationService, private router: Router, - private logService: LogService, private syncService: SyncService, - ) {} + ) { + this.organizationId = data && data.organizationId ? data.organizationId : null; + } - async submit() { - try { - this.formPromise = this.userVerificationService - .buildRequest(this.masterPassword) - .then((request) => this.apiService.postPurgeCiphers(request, this.organizationId)); - await this.formPromise; - this.platformUtilsService.showToast("success", null, this.i18nService.t("vaultPurged")); - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.syncService.fullSync(true); - if (this.organizationId != null) { - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.router.navigate(["organizations", this.organizationId, "vault"]); - } else { - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.router.navigate(["vault"]); - } - } catch (e) { - this.logService.error(e); + submit = async () => { + const response = this.userVerificationService + .buildRequest(this.formGroup.value.masterPassword) + .then((request) => this.apiService.postPurgeCiphers(request, this.organizationId)); + await response; + this.platformUtilsService.showToast("success", null, this.i18nService.t("vaultPurged")); + await this.syncService.fullSync(true); + if (this.organizationId != null) { + await this.router.navigate(["organizations", this.organizationId, "vault"]); + } else { + await this.router.navigate(["vault"]); } + this.dialogRef.close(); + }; + + static open(dialogService: DialogService, config?: DialogConfig) { + return dialogService.open(PurgeVaultComponent, config); } } diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 3f67df15be7..e79497d25e4 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -8223,6 +8223,12 @@ } } }, + "lowKDFIterationsBanner": { + "message": "Low KDF iterations. Increase your iterations to improve the security of your account." + }, + "changeKDFSettings": { + "message": "Change KDF settings" + }, "secureYourInfrastructure": { "message": "Secure your infrastructure" }, diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index c812f5fbec2..1f7b714fc85 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -193,13 +193,11 @@ import { SearchService } from "@bitwarden/common/services/search.service"; import { VaultTimeoutSettingsService } from "@bitwarden/common/services/vault-timeout/vault-timeout-settings.service"; import { VaultTimeoutService } from "@bitwarden/common/services/vault-timeout/vault-timeout.service"; import { - PasswordGenerationService, - PasswordGenerationServiceAbstraction, -} from "@bitwarden/common/tools/generator/password"; -import { - UsernameGenerationService, - UsernameGenerationServiceAbstraction, -} from "@bitwarden/common/tools/generator/username"; + legacyPasswordGenerationServiceFactory, + legacyUsernameGenerationServiceFactory, +} from "@bitwarden/common/tools/generator"; +import { PasswordGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/password"; +import { UsernameGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/username"; import { PasswordStrengthService, PasswordStrengthServiceAbstraction, @@ -559,13 +557,27 @@ const safeProviders: SafeProvider[] = [ }), safeProvider({ provide: PasswordGenerationServiceAbstraction, - useClass: PasswordGenerationService, - deps: [CryptoServiceAbstraction, PolicyServiceAbstraction, StateServiceAbstraction], + useFactory: legacyPasswordGenerationServiceFactory, + deps: [ + EncryptService, + CryptoServiceAbstraction, + PolicyServiceAbstraction, + AccountServiceAbstraction, + StateProvider, + ], }), safeProvider({ provide: UsernameGenerationServiceAbstraction, - useClass: UsernameGenerationService, - deps: [CryptoServiceAbstraction, StateServiceAbstraction, ApiServiceAbstraction], + useFactory: legacyUsernameGenerationServiceFactory, + deps: [ + ApiServiceAbstraction, + I18nServiceAbstraction, + CryptoServiceAbstraction, + EncryptService, + PolicyServiceAbstraction, + AccountServiceAbstraction, + StateProvider, + ], }), safeProvider({ provide: ApiServiceAbstraction, diff --git a/libs/angular/src/tools/generator/components/generator.component.ts b/libs/angular/src/tools/generator/components/generator.component.ts index 5015fca7fce..b94d9bc6f0e 100644 --- a/libs/angular/src/tools/generator/components/generator.component.ts +++ b/libs/angular/src/tools/generator/components/generator.component.ts @@ -1,15 +1,14 @@ -import { Directive, EventEmitter, Input, OnInit, Output } from "@angular/core"; +import { Directive, EventEmitter, Input, NgZone, OnDestroy, OnInit, Output } from "@angular/core"; import { ActivatedRoute } from "@angular/router"; -import { BehaviorSubject, firstValueFrom } from "rxjs"; -import { debounceTime, first, map } from "rxjs/operators"; +import { BehaviorSubject, combineLatest, firstValueFrom, Subject } from "rxjs"; +import { debounceTime, first, map, skipWhile, takeUntil } from "rxjs/operators"; import { PasswordGeneratorPolicyOptions } from "@bitwarden/common/admin-console/models/domain/password-generator-policy-options"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; -import { GeneratorOptions } from "@bitwarden/common/tools/generator/generator-options"; +import { GeneratorType } from "@bitwarden/common/tools/generator/generator-type"; import { PasswordGenerationServiceAbstraction, PasswordGeneratorOptions, @@ -22,9 +21,9 @@ import { import { EmailForwarderOptions } from "@bitwarden/common/tools/models/domain/email-forwarder-options"; @Directive() -export class GeneratorComponent implements OnInit { +export class GeneratorComponent implements OnInit, OnDestroy { @Input() comingFromAddEdit = false; - @Input() type: string; + @Input() type: GeneratorType | ""; @Output() onSelected = new EventEmitter(); usernameGeneratingPromise: Promise; @@ -43,6 +42,9 @@ export class GeneratorComponent implements OnInit { enforcedPasswordPolicyOptions: PasswordGeneratorPolicyOptions; usernameWebsite: string = null; + private destroy$ = new Subject(); + private isInitialized$ = new BehaviorSubject(false); + // update screen reader minimum password length with 500ms debounce // so that the user isn't flooded with status updates private _passwordOptionsMinLengthForReader = new BehaviorSubject( @@ -53,15 +55,17 @@ export class GeneratorComponent implements OnInit { debounceTime(500), ); + private _password = new BehaviorSubject("-"); + constructor( protected passwordGenerationService: PasswordGenerationServiceAbstraction, protected usernameGenerationService: UsernameGenerationServiceAbstraction, protected platformUtilsService: PlatformUtilsService, - protected stateService: StateService, + protected accountService: AccountService, protected i18nService: I18nService, protected logService: LogService, protected route: ActivatedRoute, - protected accountService: AccountService, + protected ngZone: NgZone, private win: Window, ) { this.typeOptions = [ @@ -92,61 +96,115 @@ export class GeneratorComponent implements OnInit { ]; this.subaddressOptions = [{ name: i18nService.t("random"), value: "random" }]; this.catchallOptions = [{ name: i18nService.t("random"), value: "random" }]; - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.initForwardOptions(); - } - async ngOnInit() { - // eslint-disable-next-line rxjs/no-async-subscribe - this.route.queryParams.pipe(first()).subscribe(async (qParams) => { - const passwordOptionsResponse = await this.passwordGenerationService.getOptions(); - this.passwordOptions = passwordOptionsResponse[0]; - this.enforcedPasswordPolicyOptions = passwordOptionsResponse[1]; - this.avoidAmbiguous = !this.passwordOptions.ambiguous; - this.passwordOptions.type = - this.passwordOptions.type === "passphrase" ? "passphrase" : "password"; + this.forwardOptions = [ + { name: "", value: "", validForSelfHosted: false }, + { name: "addy.io", value: "anonaddy", validForSelfHosted: true }, + { name: "DuckDuckGo", value: "duckduckgo", validForSelfHosted: false }, + { name: "Fastmail", value: "fastmail", validForSelfHosted: true }, + { name: "Firefox Relay", value: "firefoxrelay", validForSelfHosted: false }, + { name: "SimpleLogin", value: "simplelogin", validForSelfHosted: true }, + { name: "Forward Email", value: "forwardemail", validForSelfHosted: true }, + ].sort((a, b) => a.name.localeCompare(b.name)); - this.usernameOptions = await this.usernameGenerationService.getOptions(); - if (this.usernameOptions.type == null) { - this.usernameOptions.type = "word"; - } - if ( - this.usernameOptions.subaddressEmail == null || - this.usernameOptions.subaddressEmail === "" - ) { - this.usernameOptions.subaddressEmail = await firstValueFrom( - this.accountService.activeAccount$.pipe(map((a) => a?.email)), - ); - } - if (this.usernameWebsite == null) { - this.usernameOptions.subaddressType = this.usernameOptions.catchallType = "random"; - } else { - this.usernameOptions.website = this.usernameWebsite; - const websiteOption = { name: this.i18nService.t("websiteName"), value: "website-name" }; - this.subaddressOptions.push(websiteOption); - this.catchallOptions.push(websiteOption); - } - - if (this.type !== "username" && this.type !== "password") { - if (qParams.type === "username" || qParams.type === "password") { - this.type = qParams.type; - } else { - const generatorOptions = await this.stateService.getGeneratorOptions(); - this.type = generatorOptions?.type ?? "password"; - } - } - if (this.regenerateWithoutButtonPress()) { - await this.regenerate(); - } + this._password.pipe(debounceTime(250)).subscribe((password) => { + ngZone.run(() => { + this.password = password; + }); + this.passwordGenerationService.addHistory(this.password).catch((e) => { + this.logService.error(e); + }); }); } - async typeChanged() { - await this.stateService.setGeneratorOptions({ type: this.type } as GeneratorOptions); - if (this.regenerateWithoutButtonPress()) { - await this.regenerate(); + cascadeOptions(navigationType: GeneratorType = undefined, accountEmail: string) { + this.avoidAmbiguous = !this.passwordOptions.ambiguous; + + if (!this.type) { + if (navigationType) { + this.type = navigationType; + } else { + this.type = this.passwordOptions.type === "username" ? "username" : "password"; + } } + + this.passwordOptions.type = + this.passwordOptions.type === "passphrase" ? "passphrase" : "password"; + + if (this.usernameOptions.type == null) { + this.usernameOptions.type = "word"; + } + if ( + this.usernameOptions.subaddressEmail == null || + this.usernameOptions.subaddressEmail === "" + ) { + this.usernameOptions.subaddressEmail = accountEmail; + } + if (this.usernameWebsite == null) { + this.usernameOptions.subaddressType = this.usernameOptions.catchallType = "random"; + } else { + this.usernameOptions.website = this.usernameWebsite; + const websiteOption = { name: this.i18nService.t("websiteName"), value: "website-name" }; + this.subaddressOptions.push(websiteOption); + this.catchallOptions.push(websiteOption); + } + } + + async ngOnInit() { + combineLatest([ + this.route.queryParams.pipe(first()), + this.accountService.activeAccount$.pipe(first()), + this.passwordGenerationService.getOptions$(), + this.usernameGenerationService.getOptions$(), + ]) + .pipe( + map(([qParams, account, [passwordOptions, passwordPolicy], usernameOptions]) => ({ + navigationType: qParams.type as GeneratorType, + accountEmail: account.email, + passwordOptions, + passwordPolicy, + usernameOptions, + })), + takeUntil(this.destroy$), + ) + .subscribe((options) => { + this.passwordOptions = options.passwordOptions; + this.enforcedPasswordPolicyOptions = options.passwordPolicy; + this.usernameOptions = options.usernameOptions; + + this.cascadeOptions(options.navigationType, options.accountEmail); + this._passwordOptionsMinLengthForReader.next(this.passwordOptions.minLength); + + if (this.regenerateWithoutButtonPress()) { + this.regenerate().catch((e) => { + this.logService.error(e); + }); + } + + this.isInitialized$.next(true); + }); + + // once initialization is complete, `ngOnInit` should return. + // + // FIXME(#6944): if a sync is in progress, wait to complete until after + // the sync completes. + await firstValueFrom( + this.isInitialized$.pipe( + skipWhile((initialized) => !initialized), + takeUntil(this.destroy$), + ), + ); + } + + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); + this.isInitialized$.complete(); + this._passwordOptionsMinLengthForReader.complete(); + } + + async typeChanged() { + await this.savePasswordOptions(); } async regenerate() { @@ -160,7 +218,7 @@ export class GeneratorComponent implements OnInit { async sliderChanged() { // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.savePasswordOptions(false); + this.savePasswordOptions(); await this.passwordGenerationService.addHistory(this.password); } @@ -204,31 +262,34 @@ export class GeneratorComponent implements OnInit { async sliderInput() { await this.normalizePasswordOptions(); - this.password = await this.passwordGenerationService.generatePassword(this.passwordOptions); } - async savePasswordOptions(regenerate = true) { + async savePasswordOptions() { + // map navigation state into generator type + const restoreType = this.passwordOptions.type; + if (this.type === "username") { + this.passwordOptions.type = this.type; + } + + // save options await this.normalizePasswordOptions(); await this.passwordGenerationService.saveOptions(this.passwordOptions); - if (regenerate && this.regenerateWithoutButtonPress()) { - await this.regeneratePassword(); - } + // restore the original format + this.passwordOptions.type = restoreType; } - async saveUsernameOptions(regenerate = true) { + async saveUsernameOptions() { await this.usernameGenerationService.saveOptions(this.usernameOptions); if (this.usernameOptions.type === "forwarded") { this.username = "-"; } - if (regenerate && this.regenerateWithoutButtonPress()) { - await this.regenerateUsername(); - } } async regeneratePassword() { - this.password = await this.passwordGenerationService.generatePassword(this.passwordOptions); - await this.passwordGenerationService.addHistory(this.password); + this._password.next( + await this.passwordGenerationService.generatePassword(this.passwordOptions), + ); } regenerateUsername() { @@ -297,28 +358,5 @@ export class GeneratorComponent implements OnInit { await this.passwordGenerationService.enforcePasswordGeneratorPoliciesOnOptions( this.passwordOptions, ); - - this._passwordOptionsMinLengthForReader.next(this.passwordOptions.minLength); - } - - private async initForwardOptions() { - this.forwardOptions = [ - { name: "addy.io", value: "anonaddy", validForSelfHosted: true }, - { name: "DuckDuckGo", value: "duckduckgo", validForSelfHosted: false }, - { name: "Fastmail", value: "fastmail", validForSelfHosted: true }, - { name: "Firefox Relay", value: "firefoxrelay", validForSelfHosted: false }, - { name: "SimpleLogin", value: "simplelogin", validForSelfHosted: true }, - { name: "Forward Email", value: "forwardemail", validForSelfHosted: true }, - ]; - - this.usernameOptions = await this.usernameGenerationService.getOptions(); - if ( - this.usernameOptions.forwardedService == null || - this.usernameOptions.forwardedService === "" - ) { - this.forwardOptions.push({ name: "", value: null, validForSelfHosted: false }); - } - - this.forwardOptions = this.forwardOptions.sort((a, b) => a.name.localeCompare(b.name)); } } diff --git a/libs/angular/src/tools/generator/components/password-generator-history.component.ts b/libs/angular/src/tools/generator/components/password-generator-history.component.ts index 7197e9bf4c5..9ad0c0cbdbb 100644 --- a/libs/angular/src/tools/generator/components/password-generator-history.component.ts +++ b/libs/angular/src/tools/generator/components/password-generator-history.component.ts @@ -23,8 +23,7 @@ export class PasswordGeneratorHistoryComponent implements OnInit { } clear = async () => { - this.history = []; - await this.passwordGenerationService.clear(); + this.history = await this.passwordGenerationService.clear(); }; copy(password: string) { diff --git a/apps/web/src/app/auth/anon-layout-wrapper.component.html b/libs/auth/src/angular/anon-layout/anon-layout-wrapper.component.html similarity index 100% rename from apps/web/src/app/auth/anon-layout-wrapper.component.html rename to libs/auth/src/angular/anon-layout/anon-layout-wrapper.component.html diff --git a/apps/web/src/app/auth/anon-layout-wrapper.component.ts b/libs/auth/src/angular/anon-layout/anon-layout-wrapper.component.ts similarity index 89% rename from apps/web/src/app/auth/anon-layout-wrapper.component.ts rename to libs/auth/src/angular/anon-layout/anon-layout-wrapper.component.ts index c89b05a3c65..fb98825b1b6 100644 --- a/apps/web/src/app/auth/anon-layout-wrapper.component.ts +++ b/libs/auth/src/angular/anon-layout/anon-layout-wrapper.component.ts @@ -5,6 +5,12 @@ import { AnonLayoutComponent } from "@bitwarden/auth/angular"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { Icon } from "@bitwarden/components"; +export interface AnonLayoutWrapperData { + pageTitle?: string; + pageSubtitle?: string; + pageIcon?: Icon; +} + @Component({ standalone: true, templateUrl: "anon-layout-wrapper.component.html", diff --git a/libs/auth/src/angular/anon-layout/anon-layout.component.html b/libs/auth/src/angular/anon-layout/anon-layout.component.html index 6483048a238..1152d2efbbd 100644 --- a/libs/auth/src/angular/anon-layout/anon-layout.component.html +++ b/libs/auth/src/angular/anon-layout/anon-layout.component.html @@ -13,7 +13,7 @@

{{ subtitle }}

-
+
diff --git a/libs/auth/src/angular/anon-layout/anon-layout.mdx b/libs/auth/src/angular/anon-layout/anon-layout.mdx index c604c02f030..b98523bba18 100644 --- a/libs/auth/src/angular/anon-layout/anon-layout.mdx +++ b/libs/auth/src/angular/anon-layout/anon-layout.mdx @@ -31,14 +31,13 @@ writing: Instead the AnonLayoutComponent is implemented solely in the router via routable composition, which gives us the advantages of nested routes in Angular. -To allow for routable composition, Auth will also provide a wrapper component in each client, called -AnonLayout**Wrapper**Component. +To allow for routable composition, Auth also provides an AnonLayout**Wrapper**Component which embeds +the AnonLayoutComponent. For clarity: -- AnonLayoutComponent = the Auth-owned library component - `` -- AnonLayout**Wrapper**Component = the client-specific wrapper component to be used in a client - routing module +- AnonLayoutComponent = the base, Auth-owned library component - `` +- AnonLayout**Wrapper**Component = the wrapper to be used in client routing modules The AnonLayout**Wrapper**Component embeds the AnonLayoutComponent along with the router outlets: @@ -79,7 +78,7 @@ example) to construct the page via routable composition: pageTitle: "logIn", // example of a translation key from messages.json pageSubtitle: "loginWithMasterPassword", // example of a translation key from messages.json pageIcon: LockIcon, // example of an icon to pass in - }, + } satisfies AnonLayoutWrapperData, }, ], }, @@ -99,7 +98,7 @@ In the `oss-routing.module.ts` example above, notice the data properties being p All 3 of these properties are optional. ```javascript -import { LockIcon } from "@bitwarden/auth/angular"; +import { AnonLayoutWrapperData, LockIcon } from "@bitwarden/auth/angular"; // ... @@ -109,7 +108,7 @@ import { LockIcon } from "@bitwarden/auth/angular"; pageTitle: "logIn", pageSubtitle: "loginWithMasterPassword", pageIcon: LockIcon, - }, + } satisfies AnonLayoutWrapperData, } ``` diff --git a/libs/auth/src/angular/anon-layout/anon-layout.stories.ts b/libs/auth/src/angular/anon-layout/anon-layout.stories.ts index 61a395b1559..103098349a0 100644 --- a/libs/auth/src/angular/anon-layout/anon-layout.stories.ts +++ b/libs/auth/src/angular/anon-layout/anon-layout.stories.ts @@ -41,7 +41,7 @@ export const WithPrimaryContent: Story = { // Projected content (the
) and styling is just a sample and can be replaced with any content/styling. ` -
+
Primary Projected Content Area (customizable)
Lorem ipsum dolor sit amet consectetur adipisicing elit. Necessitatibus illum vero, placeat recusandae esse ratione eius minima veniam nemo, quas beatae! Impedit molestiae alias sapiente explicabo. Sapiente corporis ipsa numquam?
@@ -58,12 +58,12 @@ export const WithSecondaryContent: Story = { // Notice that slot="secondary" is requred to project any secondary content. ` -
+
Primary Projected Content Area (customizable)
Lorem ipsum dolor sit amet consectetur adipisicing elit. Necessitatibus illum vero, placeat recusandae esse ratione eius minima veniam nemo, quas beatae! Impedit molestiae alias sapiente explicabo. Sapiente corporis ipsa numquam?
-
+
Secondary Projected Content (optional)
@@ -79,12 +79,12 @@ export const WithLongContent: Story = { // Projected content (the
's) and styling is just a sample and can be replaced with any content/styling. ` -
+
Primary Projected Content Area (customizable)
Lorem ipsum dolor sit amet consectetur adipisicing elit. Necessitatibus illum vero, placeat recusandae esse ratione eius minima veniam nemo, quas beatae! Impedit molestiae alias sapiente explicabo. Sapiente corporis ipsa numquam? Lorem ipsum dolor sit amet consectetur adipisicing elit. Lorem ipsum dolor sit amet consectetur adipisicing elit. Lorem ipsum dolor sit amet consectetur adipisicing elit. Lorem ipsum dolor sit amet consectetur adipisicing elit. Lorem ipsum dolor sit amet consectetur adipisicing elit. Lorem ipsum dolor sit amet consectetur adipisicing elit. Lorem ipsum dolor sit amet consectetur adipisicing elit. Lorem ipsum dolor sit amet consectetur adipisicing elit. Lorem ipsum dolor sit amet consectetur adipisicing elit.
-
+
Secondary Projected Content (optional)

Lorem ipsum dolor sit amet consectetur adipisicing elit. Molestias laborum nostrum natus. Lorem ipsum dolor sit amet consectetur adipisicing elit. Molestias laborum nostrum natus. Expedita, quod est?

@@ -101,7 +101,7 @@ export const WithIcon: Story = { // Projected content (the
) and styling is just a sample and can be replaced with any content/styling. ` -
+
Primary Projected Content Area (customizable)
Lorem ipsum dolor sit amet consectetur adipisicing elit. Necessitatibus illum vero, placeat recusandae esse ratione eius minima veniam nemo, quas beatae! Impedit molestiae alias sapiente explicabo. Sapiente corporis ipsa numquam?
diff --git a/libs/auth/src/angular/index.ts b/libs/auth/src/angular/index.ts index 067ed63b8ef..474ef17d932 100644 --- a/libs/auth/src/angular/index.ts +++ b/libs/auth/src/angular/index.ts @@ -6,6 +6,7 @@ export * from "./icons"; export * from "./anon-layout/anon-layout.component"; +export * from "./anon-layout/anon-layout-wrapper.component"; export * from "./fingerprint-dialog/fingerprint-dialog.component"; export * from "./password-callout/password-callout.component"; diff --git a/libs/auth/src/common/login-strategies/auth-request-login.strategy.spec.ts b/libs/auth/src/common/login-strategies/auth-request-login.strategy.spec.ts index d2aac323bf3..36c572af852 100644 --- a/libs/auth/src/common/login-strategies/auth-request-login.strategy.spec.ts +++ b/libs/auth/src/common/login-strategies/auth-request-login.strategy.spec.ts @@ -97,6 +97,7 @@ describe("AuthRequestLoginStrategy", () => { authRequestLoginStrategy = new AuthRequestLoginStrategy( cache, + deviceTrustService, accountService, masterPasswordService, cryptoService, @@ -109,7 +110,6 @@ describe("AuthRequestLoginStrategy", () => { stateService, twoFactorService, userDecryptionOptions, - deviceTrustService, billingAccountProfileStateService, vaultTimeoutSettingsService, kdfConfigService, diff --git a/libs/auth/src/common/login-strategies/auth-request-login.strategy.ts b/libs/auth/src/common/login-strategies/auth-request-login.strategy.ts index 54654e1d82a..c8fc066fe0e 100644 --- a/libs/auth/src/common/login-strategies/auth-request-login.strategy.ts +++ b/libs/auth/src/common/login-strategies/auth-request-login.strategy.ts @@ -1,28 +1,13 @@ import { firstValueFrom, Observable, map, BehaviorSubject } from "rxjs"; import { Jsonify } from "type-fest"; -import { ApiService } from "@bitwarden/common/abstractions/api.service"; -import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service"; -import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; -import { KdfConfigService } from "@bitwarden/common/auth/abstractions/kdf-config.service"; -import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction"; -import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; -import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service"; +import { DeviceTrustServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust.service.abstraction"; import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result"; import { PasswordTokenRequest } from "@bitwarden/common/auth/models/request/identity-token/password-token.request"; import { TokenTwoFactorRequest } from "@bitwarden/common/auth/models/request/identity-token/token-two-factor.request"; import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/identity-token.response"; -import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; -import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; -import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; -import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; -import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; -import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; -import { DeviceTrustServiceAbstraction } from "@bitwarden/common/src/auth/abstractions/device-trust.service.abstraction"; import { UserId } from "@bitwarden/common/types/guid"; -import { InternalUserDecryptionOptionsServiceAbstraction } from "../abstractions/user-decryption-options.service.abstraction"; import { AuthRequestLoginCredentials } from "../models/domain/login-credentials"; import { CacheData } from "../services/login-strategies/login-strategy.state"; @@ -51,40 +36,10 @@ export class AuthRequestLoginStrategy extends LoginStrategy { constructor( data: AuthRequestLoginStrategyData, - accountService: AccountService, - masterPasswordService: InternalMasterPasswordServiceAbstraction, - cryptoService: CryptoService, - apiService: ApiService, - tokenService: TokenService, - appIdService: AppIdService, - platformUtilsService: PlatformUtilsService, - messagingService: MessagingService, - logService: LogService, - stateService: StateService, - twoFactorService: TwoFactorService, - userDecryptionOptionsService: InternalUserDecryptionOptionsServiceAbstraction, private deviceTrustService: DeviceTrustServiceAbstraction, - billingAccountProfileStateService: BillingAccountProfileStateService, - vaultTimeoutSettingsService: VaultTimeoutSettingsService, - kdfConfigService: KdfConfigService, + ...sharedDeps: ConstructorParameters ) { - super( - accountService, - masterPasswordService, - cryptoService, - apiService, - tokenService, - appIdService, - platformUtilsService, - messagingService, - logService, - stateService, - twoFactorService, - userDecryptionOptionsService, - billingAccountProfileStateService, - vaultTimeoutSettingsService, - kdfConfigService, - ); + super(...sharedDeps); this.cache = new BehaviorSubject(data); this.email$ = this.cache.pipe(map((data) => data.tokenRequest.email)); diff --git a/libs/auth/src/common/login-strategies/login.strategy.spec.ts b/libs/auth/src/common/login-strategies/login.strategy.spec.ts index 627c852076a..665857c1f47 100644 --- a/libs/auth/src/common/login-strategies/login.strategy.spec.ts +++ b/libs/auth/src/common/login-strategies/login.strategy.spec.ts @@ -150,6 +150,9 @@ describe("LoginStrategy", () => { // The base class is abstract so we test it via PasswordLoginStrategy passwordLoginStrategy = new PasswordLoginStrategy( cache, + passwordStrengthService, + policyService, + loginStrategyService, accountService, masterPasswordService, cryptoService, @@ -162,9 +165,6 @@ describe("LoginStrategy", () => { stateService, twoFactorService, userDecryptionOptionsService, - passwordStrengthService, - policyService, - loginStrategyService, billingAccountProfileStateService, vaultTimeoutSettingsService, kdfConfigService, @@ -461,6 +461,9 @@ describe("LoginStrategy", () => { passwordLoginStrategy = new PasswordLoginStrategy( cache, + passwordStrengthService, + policyService, + loginStrategyService, accountService, masterPasswordService, cryptoService, @@ -473,9 +476,6 @@ describe("LoginStrategy", () => { stateService, twoFactorService, userDecryptionOptionsService, - passwordStrengthService, - policyService, - loginStrategyService, billingAccountProfileStateService, vaultTimeoutSettingsService, kdfConfigService, diff --git a/libs/auth/src/common/login-strategies/password-login.strategy.spec.ts b/libs/auth/src/common/login-strategies/password-login.strategy.spec.ts index b6d1e07a261..7ba58e1443a 100644 --- a/libs/auth/src/common/login-strategies/password-login.strategy.spec.ts +++ b/libs/auth/src/common/login-strategies/password-login.strategy.spec.ts @@ -121,6 +121,9 @@ describe("PasswordLoginStrategy", () => { passwordLoginStrategy = new PasswordLoginStrategy( cache, + passwordStrengthService, + policyService, + loginStrategyService, accountService, masterPasswordService, cryptoService, @@ -133,9 +136,6 @@ describe("PasswordLoginStrategy", () => { stateService, twoFactorService, userDecryptionOptionsService, - passwordStrengthService, - policyService, - loginStrategyService, billingAccountProfileStateService, vaultTimeoutSettingsService, kdfConfigService, diff --git a/libs/auth/src/common/login-strategies/password-login.strategy.ts b/libs/auth/src/common/login-strategies/password-login.strategy.ts index b855e25e1d2..7f73898ff6e 100644 --- a/libs/auth/src/common/login-strategies/password-login.strategy.ts +++ b/libs/auth/src/common/login-strategies/password-login.strategy.ts @@ -1,15 +1,8 @@ import { BehaviorSubject, firstValueFrom, map, Observable } from "rxjs"; import { Jsonify } from "type-fest"; -import { ApiService } from "@bitwarden/common/abstractions/api.service"; -import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options"; -import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; -import { KdfConfigService } from "@bitwarden/common/auth/abstractions/kdf-config.service"; -import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction"; -import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; -import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service"; import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result"; import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason"; import { PasswordTokenRequest } from "@bitwarden/common/auth/models/request/identity-token/password-token.request"; @@ -17,13 +10,6 @@ import { TokenTwoFactorRequest } from "@bitwarden/common/auth/models/request/ide import { IdentityCaptchaResponse } from "@bitwarden/common/auth/models/response/identity-captcha.response"; import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/identity-token.response"; import { IdentityTwoFactorResponse } from "@bitwarden/common/auth/models/response/identity-two-factor.response"; -import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; -import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; -import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; -import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; -import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; -import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { HashPurpose } from "@bitwarden/common/platform/enums"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength"; @@ -31,7 +17,6 @@ import { UserId } from "@bitwarden/common/types/guid"; import { MasterKey } from "@bitwarden/common/types/key"; import { LoginStrategyServiceAbstraction } from "../abstractions"; -import { InternalUserDecryptionOptionsServiceAbstraction } from "../abstractions/user-decryption-options.service.abstraction"; import { PasswordLoginCredentials } from "../models/domain/login-credentials"; import { CacheData } from "../services/login-strategies/login-strategy.state"; @@ -75,42 +60,12 @@ export class PasswordLoginStrategy extends LoginStrategy { constructor( data: PasswordLoginStrategyData, - accountService: AccountService, - masterPasswordService: InternalMasterPasswordServiceAbstraction, - cryptoService: CryptoService, - apiService: ApiService, - tokenService: TokenService, - appIdService: AppIdService, - platformUtilsService: PlatformUtilsService, - messagingService: MessagingService, - logService: LogService, - protected stateService: StateService, - twoFactorService: TwoFactorService, - userDecryptionOptionsService: InternalUserDecryptionOptionsServiceAbstraction, private passwordStrengthService: PasswordStrengthServiceAbstraction, private policyService: PolicyService, private loginStrategyService: LoginStrategyServiceAbstraction, - billingAccountProfileStateService: BillingAccountProfileStateService, - vaultTimeoutSettingsService: VaultTimeoutSettingsService, - kdfConfigService: KdfConfigService, + ...sharedDeps: ConstructorParameters ) { - super( - accountService, - masterPasswordService, - cryptoService, - apiService, - tokenService, - appIdService, - platformUtilsService, - messagingService, - logService, - stateService, - twoFactorService, - userDecryptionOptionsService, - billingAccountProfileStateService, - vaultTimeoutSettingsService, - kdfConfigService, - ); + super(...sharedDeps); this.cache = new BehaviorSubject(data); this.email$ = this.cache.pipe(map((state) => state.tokenRequest.email)); diff --git a/libs/auth/src/common/login-strategies/sso-login.strategy.spec.ts b/libs/auth/src/common/login-strategies/sso-login.strategy.spec.ts index b6290742be5..492772081d6 100644 --- a/libs/auth/src/common/login-strategies/sso-login.strategy.spec.ts +++ b/libs/auth/src/common/login-strategies/sso-login.strategy.spec.ts @@ -118,6 +118,10 @@ describe("SsoLoginStrategy", () => { ssoLoginStrategy = new SsoLoginStrategy( null, + keyConnectorService, + deviceTrustService, + authRequestService, + i18nService, accountService, masterPasswordService, cryptoService, @@ -130,10 +134,6 @@ describe("SsoLoginStrategy", () => { stateService, twoFactorService, userDecryptionOptionsService, - keyConnectorService, - deviceTrustService, - authRequestService, - i18nService, billingAccountProfileStateService, vaultTimeoutSettingsService, kdfConfigService, diff --git a/libs/auth/src/common/login-strategies/sso-login.strategy.ts b/libs/auth/src/common/login-strategies/sso-login.strategy.ts index 414af4c1a3f..b7ed8906e70 100644 --- a/libs/auth/src/common/login-strategies/sso-login.strategy.ts +++ b/libs/auth/src/common/login-strategies/sso-login.strategy.ts @@ -1,36 +1,19 @@ import { firstValueFrom, Observable, map, BehaviorSubject } from "rxjs"; import { Jsonify } from "type-fest"; -import { ApiService } from "@bitwarden/common/abstractions/api.service"; -import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service"; -import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; -import { KdfConfigService } from "@bitwarden/common/auth/abstractions/kdf-config.service"; +import { DeviceTrustServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust.service.abstraction"; import { KeyConnectorService } from "@bitwarden/common/auth/abstractions/key-connector.service"; -import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction"; -import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; -import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service"; import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result"; import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason"; import { SsoTokenRequest } from "@bitwarden/common/auth/models/request/identity-token/sso-token.request"; import { AuthRequestResponse } from "@bitwarden/common/auth/models/response/auth-request.response"; import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/identity-token.response"; -import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { HttpStatusCode } from "@bitwarden/common/enums"; import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; -import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; -import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; -import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; -import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; -import { DeviceTrustServiceAbstraction } from "@bitwarden/common/src/auth/abstractions/device-trust.service.abstraction"; import { UserId } from "@bitwarden/common/types/guid"; -import { - InternalUserDecryptionOptionsServiceAbstraction, - AuthRequestServiceAbstraction, -} from "../abstractions"; +import { AuthRequestServiceAbstraction } from "../abstractions"; import { SsoLoginCredentials } from "../models/domain/login-credentials"; import { CacheData } from "../services/login-strategies/login-strategy.state"; @@ -84,43 +67,13 @@ export class SsoLoginStrategy extends LoginStrategy { constructor( data: SsoLoginStrategyData, - accountService: AccountService, - masterPasswordService: InternalMasterPasswordServiceAbstraction, - cryptoService: CryptoService, - apiService: ApiService, - tokenService: TokenService, - appIdService: AppIdService, - platformUtilsService: PlatformUtilsService, - messagingService: MessagingService, - logService: LogService, - stateService: StateService, - twoFactorService: TwoFactorService, - userDecryptionOptionsService: InternalUserDecryptionOptionsServiceAbstraction, private keyConnectorService: KeyConnectorService, private deviceTrustService: DeviceTrustServiceAbstraction, private authRequestService: AuthRequestServiceAbstraction, private i18nService: I18nService, - billingAccountProfileStateService: BillingAccountProfileStateService, - vaultTimeoutSettingsService: VaultTimeoutSettingsService, - kdfConfigService: KdfConfigService, + ...sharedDeps: ConstructorParameters ) { - super( - accountService, - masterPasswordService, - cryptoService, - apiService, - tokenService, - appIdService, - platformUtilsService, - messagingService, - logService, - stateService, - twoFactorService, - userDecryptionOptionsService, - billingAccountProfileStateService, - vaultTimeoutSettingsService, - kdfConfigService, - ); + super(...sharedDeps); this.cache = new BehaviorSubject(data); this.email$ = this.cache.pipe(map((state) => state.email)); diff --git a/libs/auth/src/common/login-strategies/user-api-login.strategy.spec.ts b/libs/auth/src/common/login-strategies/user-api-login.strategy.spec.ts index 8120a5ad397..6b9cddd99c5 100644 --- a/libs/auth/src/common/login-strategies/user-api-login.strategy.spec.ts +++ b/libs/auth/src/common/login-strategies/user-api-login.strategy.spec.ts @@ -94,6 +94,8 @@ describe("UserApiLoginStrategy", () => { apiLogInStrategy = new UserApiLoginStrategy( cache, + environmentService, + keyConnectorService, accountService, masterPasswordService, cryptoService, @@ -106,8 +108,6 @@ describe("UserApiLoginStrategy", () => { stateService, twoFactorService, userDecryptionOptionsService, - environmentService, - keyConnectorService, billingAccountProfileStateService, vaultTimeoutSettingsService, kdfConfigService, diff --git a/libs/auth/src/common/login-strategies/user-api-login.strategy.ts b/libs/auth/src/common/login-strategies/user-api-login.strategy.ts index 86113d36550..1faac3f6c75 100644 --- a/libs/auth/src/common/login-strategies/user-api-login.strategy.ts +++ b/libs/auth/src/common/login-strategies/user-api-login.strategy.ts @@ -1,28 +1,13 @@ import { firstValueFrom, BehaviorSubject } from "rxjs"; import { Jsonify } from "type-fest"; -import { ApiService } from "@bitwarden/common/abstractions/api.service"; -import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service"; -import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; -import { KdfConfigService } from "@bitwarden/common/auth/abstractions/kdf-config.service"; import { KeyConnectorService } from "@bitwarden/common/auth/abstractions/key-connector.service"; -import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction"; -import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; -import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service"; import { UserApiTokenRequest } from "@bitwarden/common/auth/models/request/identity-token/user-api-token.request"; import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/identity-token.response"; -import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { VaultTimeoutAction } from "@bitwarden/common/enums/vault-timeout-action.enum"; -import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; -import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; -import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; -import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; -import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { UserId } from "@bitwarden/common/types/guid"; -import { InternalUserDecryptionOptionsServiceAbstraction } from "../abstractions/user-decryption-options.service.abstraction"; import { UserApiLoginCredentials } from "../models/domain/login-credentials"; import { CacheData } from "../services/login-strategies/login-strategy.state"; @@ -44,41 +29,12 @@ export class UserApiLoginStrategy extends LoginStrategy { constructor( data: UserApiLoginStrategyData, - accountService: AccountService, - masterPasswordService: InternalMasterPasswordServiceAbstraction, - cryptoService: CryptoService, - apiService: ApiService, - tokenService: TokenService, - appIdService: AppIdService, - platformUtilsService: PlatformUtilsService, - messagingService: MessagingService, - logService: LogService, - stateService: StateService, - twoFactorService: TwoFactorService, - userDecryptionOptionsService: InternalUserDecryptionOptionsServiceAbstraction, private environmentService: EnvironmentService, private keyConnectorService: KeyConnectorService, - billingAccountProfileStateService: BillingAccountProfileStateService, - vaultTimeoutSettingsService: VaultTimeoutSettingsService, - protected kdfConfigService: KdfConfigService, + ...sharedDeps: ConstructorParameters ) { - super( - accountService, - masterPasswordService, - cryptoService, - apiService, - tokenService, - appIdService, - platformUtilsService, - messagingService, - logService, - stateService, - twoFactorService, - userDecryptionOptionsService, - billingAccountProfileStateService, - vaultTimeoutSettingsService, - kdfConfigService, - ); + super(...sharedDeps); + this.cache = new BehaviorSubject(data); } diff --git a/libs/auth/src/common/login-strategies/webauthn-login.strategy.ts b/libs/auth/src/common/login-strategies/webauthn-login.strategy.ts index 226ab1799ad..d283d163da1 100644 --- a/libs/auth/src/common/login-strategies/webauthn-login.strategy.ts +++ b/libs/auth/src/common/login-strategies/webauthn-login.strategy.ts @@ -1,28 +1,13 @@ import { BehaviorSubject } from "rxjs"; import { Jsonify } from "type-fest"; -import { ApiService } from "@bitwarden/common/abstractions/api.service"; -import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service"; -import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; -import { KdfConfigService } from "@bitwarden/common/auth/abstractions/kdf-config.service"; -import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction"; -import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; -import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service"; import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result"; import { WebAuthnLoginTokenRequest } from "@bitwarden/common/auth/models/request/identity-token/webauthn-login-token.request"; import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/identity-token.response"; -import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; -import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; -import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; -import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; -import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; -import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; import { UserId } from "@bitwarden/common/types/guid"; import { UserKey } from "@bitwarden/common/types/key"; -import { InternalUserDecryptionOptionsServiceAbstraction } from "../abstractions"; import { WebAuthnLoginCredentials } from "../models/domain/login-credentials"; import { CacheData } from "../services/login-strategies/login-strategy.state"; @@ -46,39 +31,9 @@ export class WebAuthnLoginStrategy extends LoginStrategy { constructor( data: WebAuthnLoginStrategyData, - accountService: AccountService, - masterPasswordService: InternalMasterPasswordServiceAbstraction, - cryptoService: CryptoService, - apiService: ApiService, - tokenService: TokenService, - appIdService: AppIdService, - platformUtilsService: PlatformUtilsService, - messagingService: MessagingService, - logService: LogService, - stateService: StateService, - twoFactorService: TwoFactorService, - userDecryptionOptionsService: InternalUserDecryptionOptionsServiceAbstraction, - billingAccountProfileStateService: BillingAccountProfileStateService, - vaultTimeoutSettingsService: VaultTimeoutSettingsService, - kdfConfigService: KdfConfigService, + ...sharedDeps: ConstructorParameters ) { - super( - accountService, - masterPasswordService, - cryptoService, - apiService, - tokenService, - appIdService, - platformUtilsService, - messagingService, - logService, - stateService, - twoFactorService, - userDecryptionOptionsService, - billingAccountProfileStateService, - vaultTimeoutSettingsService, - kdfConfigService, - ); + super(...sharedDeps); this.cache = new BehaviorSubject(data); } diff --git a/libs/auth/src/common/services/login-strategies/login-strategy.service.ts b/libs/auth/src/common/services/login-strategies/login-strategy.service.ts index 46d785f9b5b..f425bc697c5 100644 --- a/libs/auth/src/common/services/login-strategies/login-strategy.service.ts +++ b/libs/auth/src/common/services/login-strategies/login-strategy.service.ts @@ -48,6 +48,7 @@ import { MasterKey } from "@bitwarden/common/types/key"; import { AuthRequestServiceAbstraction, LoginStrategyServiceAbstraction } from "../../abstractions"; import { InternalUserDecryptionOptionsServiceAbstraction } from "../../abstractions/user-decryption-options.service.abstraction"; import { AuthRequestLoginStrategy } from "../../login-strategies/auth-request-login.strategy"; +import { LoginStrategy } from "../../login-strategies/login.strategy"; import { PasswordLoginStrategy } from "../../login-strategies/password-login.strategy"; import { SsoLoginStrategy } from "../../login-strategies/sso-login.strategy"; import { UserApiLoginStrategy } from "../../login-strategies/user-api-login.strategy"; @@ -338,6 +339,24 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction { private initializeLoginStrategy( source: Observable<[AuthenticationType | null, CacheData | null]>, ) { + const sharedDeps: ConstructorParameters = [ + this.accountService, + this.masterPasswordService, + this.cryptoService, + this.apiService, + this.tokenService, + this.appIdService, + this.platformUtilsService, + this.messagingService, + this.logService, + this.stateService, + this.twoFactorService, + this.userDecryptionOptionsService, + this.billingAccountProfileStateService, + this.vaultTimeoutSettingsService, + this.kdfConfigService, + ]; + return source.pipe( map(([strategy, data]) => { if (strategy == null) { @@ -347,108 +366,35 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction { case AuthenticationType.Password: return new PasswordLoginStrategy( data?.password, - this.accountService, - this.masterPasswordService, - this.cryptoService, - this.apiService, - this.tokenService, - this.appIdService, - this.platformUtilsService, - this.messagingService, - this.logService, - this.stateService, - this.twoFactorService, - this.userDecryptionOptionsService, this.passwordStrengthService, this.policyService, this, - this.billingAccountProfileStateService, - this.vaultTimeoutSettingsService, - this.kdfConfigService, + ...sharedDeps, ); case AuthenticationType.Sso: return new SsoLoginStrategy( data?.sso, - this.accountService, - this.masterPasswordService, - this.cryptoService, - this.apiService, - this.tokenService, - this.appIdService, - this.platformUtilsService, - this.messagingService, - this.logService, - this.stateService, - this.twoFactorService, - this.userDecryptionOptionsService, this.keyConnectorService, this.deviceTrustService, this.authRequestService, this.i18nService, - this.billingAccountProfileStateService, - this.vaultTimeoutSettingsService, - this.kdfConfigService, + ...sharedDeps, ); case AuthenticationType.UserApiKey: return new UserApiLoginStrategy( data?.userApiKey, - this.accountService, - this.masterPasswordService, - this.cryptoService, - this.apiService, - this.tokenService, - this.appIdService, - this.platformUtilsService, - this.messagingService, - this.logService, - this.stateService, - this.twoFactorService, - this.userDecryptionOptionsService, this.environmentService, this.keyConnectorService, - this.billingAccountProfileStateService, - this.vaultTimeoutSettingsService, - this.kdfConfigService, + ...sharedDeps, ); case AuthenticationType.AuthRequest: return new AuthRequestLoginStrategy( data?.authRequest, - this.accountService, - this.masterPasswordService, - this.cryptoService, - this.apiService, - this.tokenService, - this.appIdService, - this.platformUtilsService, - this.messagingService, - this.logService, - this.stateService, - this.twoFactorService, - this.userDecryptionOptionsService, this.deviceTrustService, - this.billingAccountProfileStateService, - this.vaultTimeoutSettingsService, - this.kdfConfigService, + ...sharedDeps, ); case AuthenticationType.WebAuthn: - return new WebAuthnLoginStrategy( - data?.webAuthn, - this.accountService, - this.masterPasswordService, - this.cryptoService, - this.apiService, - this.tokenService, - this.appIdService, - this.platformUtilsService, - this.messagingService, - this.logService, - this.stateService, - this.twoFactorService, - this.userDecryptionOptionsService, - this.billingAccountProfileStateService, - this.vaultTimeoutSettingsService, - this.kdfConfigService, - ); + return new WebAuthnLoginStrategy(data?.webAuthn, ...sharedDeps); } }), ); diff --git a/libs/common/src/admin-console/models/domain/password-generator-policy-options.ts b/libs/common/src/admin-console/models/domain/password-generator-policy-options.ts index 9d2e7eadd59..c52962a0a1b 100644 --- a/libs/common/src/admin-console/models/domain/password-generator-policy-options.ts +++ b/libs/common/src/admin-console/models/domain/password-generator-policy-options.ts @@ -70,7 +70,7 @@ export class PasswordGeneratorPolicyOptions extends Domain { */ inEffect() { return ( - this.defaultType !== "" || + this.defaultType || this.minLength > 0 || this.numberCount > 0 || this.specialCount > 0 || diff --git a/libs/common/src/platform/services/crypto.service.ts b/libs/common/src/platform/services/crypto.service.ts index 61dc6b81ad6..7595d5a3e32 100644 --- a/libs/common/src/platform/services/crypto.service.ts +++ b/libs/common/src/platform/services/crypto.service.ts @@ -174,7 +174,7 @@ export class CryptoService implements CryptoServiceAbstraction { userId ??= await firstValueFrom(this.stateProvider.activeUserId$); masterKey ??= await firstValueFrom(this.masterPasswordService.masterKey$(userId)); - return await this.validateUserKey(masterKey as unknown as UserKey); + return await this.validateUserKey(masterKey as unknown as UserKey, userId); } // TODO: legacy support for user key is no longer needed since we require users to migrate on login @@ -193,9 +193,10 @@ export class CryptoService implements CryptoServiceAbstraction { } async getUserKeyFromStorage(keySuffix: KeySuffixOptions, userId?: UserId): Promise { + userId ??= await firstValueFrom(this.stateProvider.activeUserId$); const userKey = await this.getKeyFromStorage(keySuffix, userId); if (userKey) { - if (!(await this.validateUserKey(userKey))) { + if (!(await this.validateUserKey(userKey, userId))) { this.logService.warning("Invalid key, throwing away stored keys"); await this.clearAllStoredUserKeys(userId); } @@ -663,13 +664,15 @@ export class CryptoService implements CryptoServiceAbstraction { } // ---HELPERS--- - protected async validateUserKey(key: UserKey): Promise { + protected async validateUserKey(key: UserKey, userId: UserId): Promise { if (!key) { return false; } try { - const encPrivateKey = await firstValueFrom(this.activeUserEncryptedPrivateKeyState.state$); + const encPrivateKey = await firstValueFrom( + this.stateProvider.getUserState$(USER_ENCRYPTED_PRIVATE_KEY, userId), + ); if (encPrivateKey == null) { return false; } diff --git a/libs/common/src/platform/state/state-definitions.ts b/libs/common/src/platform/state/state-definitions.ts index 6f225f6c2fc..986c51f4b7d 100644 --- a/libs/common/src/platform/state/state-definitions.ts +++ b/libs/common/src/platform/state/state-definitions.ts @@ -160,3 +160,7 @@ export const CIPHERS_DISK_LOCAL = new StateDefinition("ciphersLocal", "disk", { export const CIPHERS_MEMORY = new StateDefinition("ciphersMemory", "memory", { browser: "memory-large-object", }); +export const PREMIUM_BANNER_DISK_LOCAL = new StateDefinition("premiumBannerReprompt", "disk", { + web: "disk-local", +}); +export const BANNERS_DISMISSED_DISK = new StateDefinition("bannersDismissed", "disk"); diff --git a/libs/common/src/state-migrations/migrate.ts b/libs/common/src/state-migrations/migrate.ts index ed438cda88d..d0543fb8c3a 100644 --- a/libs/common/src/state-migrations/migrate.ts +++ b/libs/common/src/state-migrations/migrate.ts @@ -60,13 +60,16 @@ import { RemoveLegacyEtmKeyMigrator } from "./migrations/6-remove-legacy-etm-key import { KnownAccountsMigrator } from "./migrations/60-known-accounts"; import { PinStateMigrator } from "./migrations/61-move-pin-state-to-providers"; import { VaultTimeoutSettingsServiceStateProviderMigrator } from "./migrations/62-migrate-vault-timeout-settings-svc-to-state-provider"; +import { PasswordOptionsMigrator } from "./migrations/63-migrate-password-settings"; +import { GeneratorHistoryMigrator } from "./migrations/64-migrate-generator-history"; +import { ForwarderOptionsMigrator } from "./migrations/65-migrate-forwarder-settings"; import { MoveBiometricAutoPromptToAccount } from "./migrations/7-move-biometric-auto-prompt-to-account"; import { MoveStateVersionMigrator } from "./migrations/8-move-state-version"; import { MoveBrowserSettingsToGlobal } from "./migrations/9-move-browser-settings-to-global"; import { MinVersionMigrator } from "./migrations/min-version"; export const MIN_VERSION = 3; -export const CURRENT_VERSION = 62; +export const CURRENT_VERSION = 65; export type MinVersion = typeof MIN_VERSION; export function createMigrationBuilder() { @@ -130,7 +133,10 @@ export function createMigrationBuilder() { .with(KdfConfigMigrator, 58, 59) .with(KnownAccountsMigrator, 59, 60) .with(PinStateMigrator, 60, 61) - .with(VaultTimeoutSettingsServiceStateProviderMigrator, 61, CURRENT_VERSION); + .with(VaultTimeoutSettingsServiceStateProviderMigrator, 61, 62) + .with(PasswordOptionsMigrator, 62, 63) + .with(GeneratorHistoryMigrator, 63, 64) + .with(ForwarderOptionsMigrator, 64, CURRENT_VERSION); } export async function currentVersion( diff --git a/libs/common/src/state-migrations/migrations/62-migrate-vault-timeout-settings-svc-to-state-provider.spec.ts b/libs/common/src/state-migrations/migrations/62-migrate-vault-timeout-settings-svc-to-state-provider.spec.ts index 1a736c1623a..aa715c3c06a 100644 --- a/libs/common/src/state-migrations/migrations/62-migrate-vault-timeout-settings-svc-to-state-provider.spec.ts +++ b/libs/common/src/state-migrations/migrations/62-migrate-vault-timeout-settings-svc-to-state-provider.spec.ts @@ -13,6 +13,10 @@ import { // Represents data in state service pre-migration function preMigrationJson() { return { + // desktop only global data format + "global.vaultTimeout": -1, + "global.vaultTimeoutAction": "lock", + global: { vaultTimeout: 30, vaultTimeoutAction: "lock", @@ -267,6 +271,10 @@ describe("VaultTimeoutSettingsServiceStateProviderMigrator", () => { otherStuff: "otherStuff", }); + // Expect we removed desktop specially formatted global data + expect(helper.remove).toHaveBeenCalledWith("global\\.vaultTimeout"); + expect(helper.remove).toHaveBeenCalledWith("global\\.vaultTimeoutAction"); + // User data expect(helper.set).toHaveBeenCalledWith("user1", { settings: { diff --git a/libs/common/src/state-migrations/migrations/62-migrate-vault-timeout-settings-svc-to-state-provider.ts b/libs/common/src/state-migrations/migrations/62-migrate-vault-timeout-settings-svc-to-state-provider.ts index ee9ee4c9ea3..7451fd37514 100644 --- a/libs/common/src/state-migrations/migrations/62-migrate-vault-timeout-settings-svc-to-state-provider.ts +++ b/libs/common/src/state-migrations/migrations/62-migrate-vault-timeout-settings-svc-to-state-provider.ts @@ -122,10 +122,15 @@ export class VaultTimeoutSettingsServiceStateProviderMigrator extends Migrator<6 await Promise.all([...accounts.map(({ userId, account }) => migrateAccount(userId, account))]); - // Delete global data + // Delete global data (works for browser extension and web; CLI doesn't have these as global settings). delete globalData?.vaultTimeout; delete globalData?.vaultTimeoutAction; await helper.set("global", globalData); + + // Remove desktop only settings. These aren't found by the above global key removal b/c of + // the different storage key format. This removal does not cause any issues on migrating for other clients. + await helper.remove("global\\.vaultTimeout"); + await helper.remove("global\\.vaultTimeoutAction"); } async rollback(helper: MigrationHelper): Promise { diff --git a/libs/common/src/state-migrations/migrations/63-migrate-password-settings.spec.ts b/libs/common/src/state-migrations/migrations/63-migrate-password-settings.spec.ts new file mode 100644 index 00000000000..adbe8a999f3 --- /dev/null +++ b/libs/common/src/state-migrations/migrations/63-migrate-password-settings.spec.ts @@ -0,0 +1,123 @@ +import { MigrationHelper } from "../migration-helper"; +import { mockMigrationHelper } from "../migration-helper.spec"; + +import { + ExpectedOptions, + PasswordOptionsMigrator, + NAVIGATION, + PASSWORD, + PASSPHRASE, +} from "./63-migrate-password-settings"; + +function migrationHelper(passwordGenerationOptions: ExpectedOptions) { + const helper = mockMigrationHelper( + { + global_account_accounts: { + SomeAccount: { + email: "SomeAccount", + name: "SomeAccount", + emailVerified: true, + }, + }, + SomeAccount: { + settings: { + passwordGenerationOptions, + this: { + looks: "important", + }, + }, + cant: { + touch: "this", + }, + }, + }, + 62, + ); + + return helper; +} + +function expectOtherSettingsRemain(helper: MigrationHelper) { + expect(helper.set).toHaveBeenCalledWith("SomeAccount", { + settings: { + this: { + looks: "important", + }, + }, + cant: { + touch: "this", + }, + }); +} + +describe("PasswordOptionsMigrator", () => { + describe("migrate", () => { + it("migrates generator type", async () => { + const helper = migrationHelper({ + type: "password", + }); + helper.getFromUser.mockResolvedValue({ some: { other: "data" } }); + const migrator = new PasswordOptionsMigrator(62, 63); + + await migrator.migrate(helper); + + expect(helper.setToUser).toHaveBeenCalledWith("SomeAccount", NAVIGATION, { + type: "password", + some: { other: "data" }, + }); + expectOtherSettingsRemain(helper); + }); + + it("migrates password settings", async () => { + const helper = migrationHelper({ + length: 20, + ambiguous: true, + uppercase: false, + minUppercase: 4, + lowercase: true, + minLowercase: 3, + number: false, + minNumber: 2, + special: true, + minSpecial: 1, + }); + const migrator = new PasswordOptionsMigrator(62, 63); + + await migrator.migrate(helper); + + expect(helper.setToUser).toHaveBeenCalledWith("SomeAccount", PASSWORD, { + length: 20, + ambiguous: true, + uppercase: false, + minUppercase: 4, + lowercase: true, + minLowercase: 3, + number: false, + minNumber: 2, + special: true, + minSpecial: 1, + }); + expectOtherSettingsRemain(helper); + }); + + it("migrates passphrase settings", async () => { + const helper = migrationHelper({ + numWords: 5, + wordSeparator: "4", + capitalize: true, + includeNumber: false, + }); + const migrator = new PasswordOptionsMigrator(62, 63); + + await migrator.migrate(helper); + + expect(helper.setToUser).toHaveBeenCalledWith("SomeAccount", PASSPHRASE, { + numWords: 5, + wordSeparator: "4", + capitalize: true, + includeNumber: false, + }); + expectOtherSettingsRemain(helper); + }); + }); +}); diff --git a/libs/common/src/state-migrations/migrations/63-migrate-password-settings.ts b/libs/common/src/state-migrations/migrations/63-migrate-password-settings.ts new file mode 100644 index 00000000000..a0849fd5988 --- /dev/null +++ b/libs/common/src/state-migrations/migrations/63-migrate-password-settings.ts @@ -0,0 +1,150 @@ +import { KeyDefinitionLike, MigrationHelper } from "../migration-helper"; +import { Migrator } from "../migrator"; + +/** settings targeted by migrator */ +export type AccountType = { + settings?: { + passwordGenerationOptions?: ExpectedOptions; + }; +}; + +export type GeneratorType = "password" | "passphrase" | "username"; + +/** username generation options prior to refactoring */ +export type ExpectedOptions = { + type?: GeneratorType; + length?: number; + minLength?: number; + ambiguous?: boolean; + uppercase?: boolean; + minUppercase?: number; + lowercase?: boolean; + minLowercase?: number; + number?: boolean; + minNumber?: number; + special?: boolean; + minSpecial?: number; + numWords?: number; + wordSeparator?: string; + capitalize?: boolean; + includeNumber?: boolean; +}; + +/** username generation options after refactoring */ +type ConvertedOptions = { + generator: GeneratorNavigation; + password: PasswordGenerationOptions; + passphrase: PassphraseGenerationOptions; +}; + +export const NAVIGATION: KeyDefinitionLike = { + stateDefinition: { + name: "generator", + }, + key: "generatorSettings", +}; + +export const PASSWORD: KeyDefinitionLike = { + stateDefinition: { + name: "generator", + }, + key: "passwordGeneratorSettings", +}; + +export const PASSPHRASE: KeyDefinitionLike = { + stateDefinition: { + name: "generator", + }, + key: "passphraseGeneratorSettings", +}; + +export type GeneratorNavigation = { + type?: string; +}; + +export type PassphraseGenerationOptions = { + numWords?: number; + wordSeparator?: string; + capitalize?: boolean; + includeNumber?: boolean; +}; + +export type PasswordGenerationOptions = { + length?: number; + minLength?: number; + ambiguous?: boolean; + uppercase?: boolean; + minUppercase?: number; + lowercase?: boolean; + minLowercase?: number; + number?: boolean; + minNumber?: number; + special?: boolean; + minSpecial?: number; +}; + +export class PasswordOptionsMigrator extends Migrator<62, 63> { + async migrate(helper: MigrationHelper): Promise { + const accounts = await helper.getAccounts(); + + async function migrateAccount(userId: string, account: AccountType) { + const legacyOptions = account?.settings?.passwordGenerationOptions; + + if (legacyOptions) { + const converted = convertSettings(legacyOptions); + await storeSettings(helper, userId, converted); + await deleteSettings(helper, userId, account); + } + } + + await Promise.all([...accounts.map(({ userId, account }) => migrateAccount(userId, account))]); + } + + async rollback(helper: MigrationHelper): Promise { + // not supported + } +} + +function convertSettings(options: ExpectedOptions): ConvertedOptions { + const password = { + length: options.length, + ambiguous: options.ambiguous, + uppercase: options.uppercase, + minUppercase: options.minUppercase, + lowercase: options.lowercase, + minLowercase: options.minLowercase, + number: options.number, + minNumber: options.minNumber, + special: options.special, + minSpecial: options.minSpecial, + }; + + const generator = { + type: options.type, + }; + + const passphrase = { + numWords: options.numWords, + wordSeparator: options.wordSeparator, + capitalize: options.capitalize, + includeNumber: options.includeNumber, + }; + + return { generator, password, passphrase }; +} + +async function storeSettings(helper: MigrationHelper, userId: string, converted: ConvertedOptions) { + const existing = (await helper.getFromUser(userId, NAVIGATION)) ?? {}; + const updated = Object.assign(existing, converted.generator); + + await Promise.all([ + helper.setToUser(userId, NAVIGATION, updated), + helper.setToUser(userId, PASSPHRASE, converted.passphrase), + helper.setToUser(userId, PASSWORD, converted.password), + ]); +} + +async function deleteSettings(helper: MigrationHelper, userId: string, account: AccountType) { + delete account?.settings?.passwordGenerationOptions; + await helper.set(userId, account); +} diff --git a/libs/common/src/state-migrations/migrations/64-migrate-generator-history.spec.ts b/libs/common/src/state-migrations/migrations/64-migrate-generator-history.spec.ts new file mode 100644 index 00000000000..3bcf15ceb33 --- /dev/null +++ b/libs/common/src/state-migrations/migrations/64-migrate-generator-history.spec.ts @@ -0,0 +1,68 @@ +import { MigrationHelper } from "../migration-helper"; +import { mockMigrationHelper } from "../migration-helper.spec"; + +import { + EncryptedHistory, + GeneratorHistoryMigrator, + HISTORY, +} from "./64-migrate-generator-history"; + +function migrationHelper(encrypted: EncryptedHistory) { + const helper = mockMigrationHelper( + { + global_account_accounts: { + SomeAccount: { + email: "SomeAccount", + name: "SomeAccount", + emailVerified: true, + }, + }, + SomeAccount: { + data: { + passwordGenerationHistory: { + encrypted, + }, + this: { + looks: "important", + }, + }, + cant: { + touch: "this", + }, + }, + }, + 63, + ); + + return helper; +} + +function expectOtherSettingsRemain(helper: MigrationHelper) { + expect(helper.set).toHaveBeenCalledWith("SomeAccount", { + data: { + this: { + looks: "important", + }, + }, + cant: { + touch: "this", + }, + }); +} + +describe("PasswordOptionsMigrator", () => { + describe("migrate", () => { + it("migrates generator type", async () => { + const helper = migrationHelper([{ this: "should be copied" }, { this: "too" }]); + const migrator = new GeneratorHistoryMigrator(63, 64); + + await migrator.migrate(helper); + + expect(helper.setToUser).toHaveBeenCalledWith("SomeAccount", HISTORY, [ + { this: "should be copied" }, + { this: "too" }, + ]); + expectOtherSettingsRemain(helper); + }); + }); +}); diff --git a/libs/common/src/state-migrations/migrations/64-migrate-generator-history.ts b/libs/common/src/state-migrations/migrations/64-migrate-generator-history.ts new file mode 100644 index 00000000000..3ca4c643184 --- /dev/null +++ b/libs/common/src/state-migrations/migrations/64-migrate-generator-history.ts @@ -0,0 +1,42 @@ +import { KeyDefinitionLike, MigrationHelper } from "../migration-helper"; +import { Migrator } from "../migrator"; + +/** settings targeted by migrator */ +export type AccountType = { + data?: { + passwordGenerationHistory?: { + encrypted: EncryptedHistory; + }; + }; +}; + +/** the actual data stored in the history is opaque to the migrator */ +export type EncryptedHistory = Array; + +export const HISTORY: KeyDefinitionLike = { + stateDefinition: { + name: "generator", + }, + key: "localGeneratorHistoryBuffer", +}; + +export class GeneratorHistoryMigrator extends Migrator<63, 64> { + async migrate(helper: MigrationHelper): Promise { + const accounts = await helper.getAccounts(); + + async function migrateAccount(userId: string, account: AccountType) { + const data = account?.data?.passwordGenerationHistory; + if (data && data.encrypted) { + await helper.setToUser(userId, HISTORY, data.encrypted); + delete account.data.passwordGenerationHistory; + await helper.set(userId, account); + } + } + + await Promise.all([...accounts.map(({ userId, account }) => migrateAccount(userId, account))]); + } + + async rollback(helper: MigrationHelper): Promise { + // not supported + } +} diff --git a/libs/common/src/state-migrations/migrations/65-migrate-forwarder-settings.spec.ts b/libs/common/src/state-migrations/migrations/65-migrate-forwarder-settings.spec.ts new file mode 100644 index 00000000000..3fca95ada8b --- /dev/null +++ b/libs/common/src/state-migrations/migrations/65-migrate-forwarder-settings.spec.ts @@ -0,0 +1,218 @@ +import { MigrationHelper } from "../migration-helper"; +import { mockMigrationHelper } from "../migration-helper.spec"; + +import { + ADDY_IO, + CATCHALL, + DUCK_DUCK_GO, + EFF_USERNAME, + ExpectedOptions, + FASTMAIL, + FIREFOX_RELAY, + FORWARD_EMAIL, + ForwarderOptionsMigrator, + NAVIGATION, + SIMPLE_LOGIN, + SUBADDRESS, +} from "./65-migrate-forwarder-settings"; + +function migrationHelper(usernameGenerationOptions: ExpectedOptions) { + const helper = mockMigrationHelper( + { + global_account_accounts: { + SomeAccount: { + email: "SomeAccount", + name: "SomeAccount", + emailVerified: true, + }, + }, + SomeAccount: { + settings: { + usernameGenerationOptions, + this: { + looks: "important", + }, + }, + cant: { + touch: "this", + }, + }, + }, + 64, + ); + + return helper; +} + +function expectOtherSettingsRemain(helper: MigrationHelper) { + expect(helper.set).toHaveBeenCalledWith("SomeAccount", { + settings: { + this: { + looks: "important", + }, + }, + cant: { + touch: "this", + }, + }); +} + +describe("ForwarderOptionsMigrator", () => { + describe("migrate", () => { + it("migrates generator settings", async () => { + const helper = migrationHelper({ + type: "catchall", + forwardedService: "simplelogin", + }); + const migrator = new ForwarderOptionsMigrator(64, 65); + + await migrator.migrate(helper); + + expect(helper.setToUser).toHaveBeenCalledWith("SomeAccount", NAVIGATION, { + username: "catchall", + forwarder: "simplelogin", + }); + expectOtherSettingsRemain(helper); + }); + + it("migrates catchall settings", async () => { + const helper = migrationHelper({ + catchallType: "random", + catchallDomain: "example.com", + }); + const migrator = new ForwarderOptionsMigrator(64, 65); + + await migrator.migrate(helper); + + expect(helper.setToUser).toHaveBeenCalledWith("SomeAccount", CATCHALL, { + catchallType: "random", + catchallDomain: "example.com", + }); + expectOtherSettingsRemain(helper); + }); + + it("migrates EFF username settings", async () => { + const helper = migrationHelper({ + wordCapitalize: true, + wordIncludeNumber: false, + }); + const migrator = new ForwarderOptionsMigrator(64, 65); + + await migrator.migrate(helper); + + expect(helper.setToUser).toHaveBeenCalledWith("SomeAccount", EFF_USERNAME, { + wordCapitalize: true, + wordIncludeNumber: false, + }); + expectOtherSettingsRemain(helper); + }); + + it("migrates subaddress settings", async () => { + const helper = migrationHelper({ + subaddressType: "random", + subaddressEmail: "j.d@example.com", + }); + const migrator = new ForwarderOptionsMigrator(64, 65); + + await migrator.migrate(helper); + + expect(helper.setToUser).toHaveBeenCalledWith("SomeAccount", SUBADDRESS, { + subaddressType: "random", + subaddressEmail: "j.d@example.com", + }); + expectOtherSettingsRemain(helper); + }); + + it("migrates addyIo settings", async () => { + const helper = migrationHelper({ + forwardedAnonAddyBaseUrl: "some_addyio_base", + forwardedAnonAddyApiToken: "some_addyio_token", + forwardedAnonAddyDomain: "some_addyio_domain", + }); + const migrator = new ForwarderOptionsMigrator(64, 65); + + await migrator.migrate(helper); + + expect(helper.setToUser).toHaveBeenCalledWith("SomeAccount", ADDY_IO, { + baseUrl: "some_addyio_base", + token: "some_addyio_token", + domain: "some_addyio_domain", + }); + expectOtherSettingsRemain(helper); + }); + + it("migrates DuckDuckGo settings", async () => { + const helper = migrationHelper({ + forwardedDuckDuckGoToken: "some_duckduckgo_token", + }); + const migrator = new ForwarderOptionsMigrator(64, 65); + + await migrator.migrate(helper); + + expect(helper.setToUser).toHaveBeenCalledWith("SomeAccount", DUCK_DUCK_GO, { + token: "some_duckduckgo_token", + }); + expectOtherSettingsRemain(helper); + }); + + it("migrates Firefox Relay settings", async () => { + const helper = migrationHelper({ + forwardedFirefoxApiToken: "some_firefox_token", + }); + const migrator = new ForwarderOptionsMigrator(64, 65); + + await migrator.migrate(helper); + + expect(helper.setToUser).toHaveBeenCalledWith("SomeAccount", FIREFOX_RELAY, { + token: "some_firefox_token", + }); + expectOtherSettingsRemain(helper); + }); + + it("migrates Fastmail settings", async () => { + const helper = migrationHelper({ + forwardedFastmailApiToken: "some_fastmail_token", + }); + const migrator = new ForwarderOptionsMigrator(64, 65); + + await migrator.migrate(helper); + + expect(helper.setToUser).toHaveBeenCalledWith("SomeAccount", FASTMAIL, { + token: "some_fastmail_token", + }); + expectOtherSettingsRemain(helper); + }); + + it("migrates ForwardEmail settings", async () => { + const helper = migrationHelper({ + forwardedForwardEmailApiToken: "some_forwardemail_token", + forwardedForwardEmailDomain: "some_forwardemail_domain", + }); + const migrator = new ForwarderOptionsMigrator(64, 65); + + await migrator.migrate(helper); + + expect(helper.setToUser).toHaveBeenCalledWith("SomeAccount", FORWARD_EMAIL, { + token: "some_forwardemail_token", + domain: "some_forwardemail_domain", + }); + expectOtherSettingsRemain(helper); + }); + + it("migrates SimpleLogin settings", async () => { + const helper = migrationHelper({ + forwardedSimpleLoginApiKey: "some_simplelogin_token", + forwardedSimpleLoginBaseUrl: "some_simplelogin_baseurl", + }); + const migrator = new ForwarderOptionsMigrator(64, 65); + + await migrator.migrate(helper); + + expect(helper.setToUser).toHaveBeenCalledWith("SomeAccount", SIMPLE_LOGIN, { + token: "some_simplelogin_token", + baseUrl: "some_simplelogin_baseurl", + }); + expectOtherSettingsRemain(helper); + }); + }); +}); diff --git a/libs/common/src/state-migrations/migrations/65-migrate-forwarder-settings.ts b/libs/common/src/state-migrations/migrations/65-migrate-forwarder-settings.ts new file mode 100644 index 00000000000..6dad7ae3420 --- /dev/null +++ b/libs/common/src/state-migrations/migrations/65-migrate-forwarder-settings.ts @@ -0,0 +1,245 @@ +import { KeyDefinitionLike, MigrationHelper } from "../migration-helper"; +import { Migrator } from "../migrator"; + +/** settings targeted by migrator */ +export type AccountType = { + settings?: { + usernameGenerationOptions?: ExpectedOptions; + }; +}; + +/** username generation options prior to refactoring */ +export type ExpectedOptions = { + type?: "word" | "subaddress" | "catchall" | "forwarded"; + wordCapitalize?: boolean; + wordIncludeNumber?: boolean; + subaddressType?: "random" | "website-name"; + subaddressEmail?: string; + catchallType?: "random" | "website-name"; + catchallDomain?: string; + forwardedService?: string; + forwardedAnonAddyApiToken?: string; + forwardedAnonAddyDomain?: string; + forwardedAnonAddyBaseUrl?: string; + forwardedDuckDuckGoToken?: string; + forwardedFirefoxApiToken?: string; + forwardedFastmailApiToken?: string; + forwardedForwardEmailApiToken?: string; + forwardedForwardEmailDomain?: string; + forwardedSimpleLoginApiKey?: string; + forwardedSimpleLoginBaseUrl?: string; +}; + +/** username generation options after refactoring */ +type ConvertedOptions = { + generator: GeneratorNavigation; + algorithms: { + catchall: CatchallGenerationOptions; + effUsername: EffUsernameGenerationOptions; + subaddress: SubaddressGenerationOptions; + }; + forwarders: { + addyIo: SelfHostedApiOptions & EmailDomainOptions; + duckDuckGo: ApiOptions; + fastmail: ApiOptions; + firefoxRelay: ApiOptions; + forwardEmail: ApiOptions & EmailDomainOptions; + simpleLogin: SelfHostedApiOptions; + }; +}; + +export const NAVIGATION: KeyDefinitionLike = { + stateDefinition: { + name: "generator", + }, + key: "generatorSettings", +}; + +export const CATCHALL: KeyDefinitionLike = { + stateDefinition: { + name: "generator", + }, + key: "catchallGeneratorSettings", +}; + +export const EFF_USERNAME: KeyDefinitionLike = { + stateDefinition: { + name: "generator", + }, + key: "effUsernameGeneratorSettings", +}; + +export const SUBADDRESS: KeyDefinitionLike = { + stateDefinition: { + name: "generator", + }, + key: "subaddressGeneratorSettings", +}; + +export const ADDY_IO: KeyDefinitionLike = { + stateDefinition: { + name: "generator", + }, + key: "addyIoBuffer", +}; + +export const DUCK_DUCK_GO: KeyDefinitionLike = { + stateDefinition: { + name: "generator", + }, + key: "duckDuckGoBuffer", +}; + +export const FASTMAIL: KeyDefinitionLike = { + stateDefinition: { + name: "generator", + }, + key: "fastmailBuffer", +}; + +export const FIREFOX_RELAY: KeyDefinitionLike = { + stateDefinition: { + name: "generator", + }, + key: "firefoxRelayBuffer", +}; + +export const FORWARD_EMAIL: KeyDefinitionLike = { + stateDefinition: { + name: "generator", + }, + key: "forwardEmailBuffer", +}; + +export const SIMPLE_LOGIN: KeyDefinitionLike = { + stateDefinition: { + name: "generator", + }, + key: "simpleLoginBuffer", +}; + +export type GeneratorNavigation = { + type?: string; + username?: string; + forwarder?: string; +}; + +type UsernameGenerationMode = "random" | "website-name"; + +type CatchallGenerationOptions = { + catchallType?: UsernameGenerationMode; + catchallDomain?: string; +}; + +type EffUsernameGenerationOptions = { + wordCapitalize?: boolean; + wordIncludeNumber?: boolean; +}; + +type SubaddressGenerationOptions = { + subaddressType?: UsernameGenerationMode; + subaddressEmail?: string; +}; + +type ApiOptions = { + token?: string; +}; + +type SelfHostedApiOptions = ApiOptions & { + baseUrl: string; +}; + +type EmailDomainOptions = { + domain: string; +}; + +export class ForwarderOptionsMigrator extends Migrator<64, 65> { + async migrate(helper: MigrationHelper): Promise { + const accounts = await helper.getAccounts(); + + async function migrateAccount(userId: string, account: AccountType) { + const legacyOptions = account?.settings?.usernameGenerationOptions; + + if (legacyOptions) { + const converted = convertSettings(legacyOptions); + await storeSettings(helper, userId, converted); + await deleteSettings(helper, userId, account); + } + } + + await Promise.all([...accounts.map(({ userId, account }) => migrateAccount(userId, account))]); + } + + async rollback(helper: MigrationHelper): Promise { + // not supported + } +} + +function convertSettings(options: ExpectedOptions): ConvertedOptions { + const forwarders = { + addyIo: { + baseUrl: options.forwardedAnonAddyBaseUrl, + token: options.forwardedAnonAddyApiToken, + domain: options.forwardedAnonAddyDomain, + }, + duckDuckGo: { + token: options.forwardedDuckDuckGoToken, + }, + fastmail: { + token: options.forwardedFastmailApiToken, + }, + firefoxRelay: { + token: options.forwardedFirefoxApiToken, + }, + forwardEmail: { + token: options.forwardedForwardEmailApiToken, + domain: options.forwardedForwardEmailDomain, + }, + simpleLogin: { + token: options.forwardedSimpleLoginApiKey, + baseUrl: options.forwardedSimpleLoginBaseUrl, + }, + }; + + const generator = { + username: options.type, + forwarder: options.forwardedService, + }; + + const algorithms = { + effUsername: { + wordCapitalize: options.wordCapitalize, + wordIncludeNumber: options.wordIncludeNumber, + }, + subaddress: { + subaddressType: options.subaddressType, + subaddressEmail: options.subaddressEmail, + }, + catchall: { + catchallType: options.catchallType, + catchallDomain: options.catchallDomain, + }, + }; + + return { generator, algorithms, forwarders }; +} + +async function storeSettings(helper: MigrationHelper, userId: string, converted: ConvertedOptions) { + await Promise.all([ + helper.setToUser(userId, NAVIGATION, converted.generator), + helper.setToUser(userId, CATCHALL, converted.algorithms.catchall), + helper.setToUser(userId, EFF_USERNAME, converted.algorithms.effUsername), + helper.setToUser(userId, SUBADDRESS, converted.algorithms.subaddress), + helper.setToUser(userId, ADDY_IO, converted.forwarders.addyIo), + helper.setToUser(userId, DUCK_DUCK_GO, converted.forwarders.duckDuckGo), + helper.setToUser(userId, FASTMAIL, converted.forwarders.fastmail), + helper.setToUser(userId, FIREFOX_RELAY, converted.forwarders.firefoxRelay), + helper.setToUser(userId, FORWARD_EMAIL, converted.forwarders.forwardEmail), + helper.setToUser(userId, SIMPLE_LOGIN, converted.forwarders.simpleLogin), + ]); +} + +async function deleteSettings(helper: MigrationHelper, userId: string, account: AccountType) { + delete account?.settings?.usernameGenerationOptions; + await helper.set(userId, account); +} diff --git a/libs/common/src/tools/generator/abstractions/generator-history.abstraction.ts b/libs/common/src/tools/generator/abstractions/generator-history.abstraction.ts index edda0dcb2ba..a1d358a13f8 100644 --- a/libs/common/src/tools/generator/abstractions/generator-history.abstraction.ts +++ b/libs/common/src/tools/generator/abstractions/generator-history.abstraction.ts @@ -38,6 +38,12 @@ export abstract class GeneratorHistoryService { */ take: (userId: UserId, credential: string) => Promise; + /** Deletes a user's credential history. + * @param userId identifies the user taking the credential. + * @returns A promise that completes when the history is cleared. + */ + clear: (userId: UserId) => Promise; + /** Lists all credentials for a user. * @param userId identifies the user listing the credential. * @remarks This field is eventually consistent with `track` and `take` operations. diff --git a/libs/common/src/tools/generator/abstractions/generator-strategy.abstraction.ts b/libs/common/src/tools/generator/abstractions/generator-strategy.abstraction.ts index 7cfe320abec..7bc0f21739f 100644 --- a/libs/common/src/tools/generator/abstractions/generator-strategy.abstraction.ts +++ b/libs/common/src/tools/generator/abstractions/generator-strategy.abstraction.ts @@ -23,9 +23,6 @@ export abstract class GeneratorStrategy { /** Identifies the policy enforced by the generator. */ policy: PolicyType; - /** Length of time in milliseconds to cache the evaluator */ - cache_ms: number; - /** Operator function that converts a policy collection observable to a single * policy evaluator observable. * @param policy The policy being evaluated. diff --git a/libs/common/src/tools/generator/abstractions/index.ts b/libs/common/src/tools/generator/abstractions/index.ts index 13dce17d170..ef40dfd434f 100644 --- a/libs/common/src/tools/generator/abstractions/index.ts +++ b/libs/common/src/tools/generator/abstractions/index.ts @@ -1,3 +1,4 @@ +export { GeneratorHistoryService } from "./generator-history.abstraction"; export { GeneratorNavigationService } from "./generator-navigation.service.abstraction"; export { GeneratorService } from "./generator.service.abstraction"; export { GeneratorStrategy } from "./generator-strategy.abstraction"; diff --git a/libs/common/src/tools/generator/abstractions/password-generation.service.abstraction.ts b/libs/common/src/tools/generator/abstractions/password-generation.service.abstraction.ts index b3bd30be5c7..f6b5ca9cabe 100644 --- a/libs/common/src/tools/generator/abstractions/password-generation.service.abstraction.ts +++ b/libs/common/src/tools/generator/abstractions/password-generation.service.abstraction.ts @@ -1,3 +1,5 @@ +import { Observable } from "rxjs"; + import { PasswordGeneratorPolicyOptions } from "../../../admin-console/models/domain/password-generator-policy-options"; import { GeneratedPasswordHistory } from "../password/generated-password-history"; import { PasswordGeneratorOptions } from "../password/password-generator-options"; @@ -7,11 +9,12 @@ export abstract class PasswordGenerationServiceAbstraction { generatePassword: (options: PasswordGeneratorOptions) => Promise; generatePassphrase: (options: PasswordGeneratorOptions) => Promise; getOptions: () => Promise<[PasswordGeneratorOptions, PasswordGeneratorPolicyOptions]>; + getOptions$: () => Observable<[PasswordGeneratorOptions, PasswordGeneratorPolicyOptions]>; enforcePasswordGeneratorPoliciesOnOptions: ( options: PasswordGeneratorOptions, ) => Promise<[PasswordGeneratorOptions, PasswordGeneratorPolicyOptions]>; saveOptions: (options: PasswordGeneratorOptions) => Promise; getHistory: () => Promise; addHistory: (password: string) => Promise; - clear: (userId?: string) => Promise; + clear: (userId?: string) => Promise; } diff --git a/libs/common/src/tools/generator/abstractions/username-generation.service.abstraction.ts b/libs/common/src/tools/generator/abstractions/username-generation.service.abstraction.ts index 02b25e6113a..f11cbf02ed2 100644 --- a/libs/common/src/tools/generator/abstractions/username-generation.service.abstraction.ts +++ b/libs/common/src/tools/generator/abstractions/username-generation.service.abstraction.ts @@ -1,3 +1,5 @@ +import { Observable } from "rxjs"; + import { UsernameGeneratorOptions } from "../username/username-generation-options"; /** @deprecated Use {@link GeneratorService} with a username {@link GeneratorStrategy} instead. */ @@ -8,5 +10,6 @@ export abstract class UsernameGenerationServiceAbstraction { generateCatchall: (options: UsernameGeneratorOptions) => Promise; generateForwarded: (options: UsernameGeneratorOptions) => Promise; getOptions: () => Promise; + getOptions$: () => Observable; saveOptions: (options: UsernameGeneratorOptions) => Promise; } diff --git a/libs/common/src/tools/generator/default-generator.service.spec.ts b/libs/common/src/tools/generator/default-generator.service.spec.ts index c93aec44d95..94d7d62fa8f 100644 --- a/libs/common/src/tools/generator/default-generator.service.spec.ts +++ b/libs/common/src/tools/generator/default-generator.service.spec.ts @@ -23,13 +23,9 @@ import { DefaultGeneratorService } from "."; function mockPolicyService(config?: { state?: BehaviorSubject }) { const service = mock(); - // FIXME: swap out the mock return value when `getAll$` becomes available const stateValue = config?.state ?? new BehaviorSubject([null]); service.getAll$.mockReturnValue(stateValue); - // const stateValue = config?.state ?? new BehaviorSubject(null); - // service.getAll$.mockReturnValue(stateValue); - return service; } diff --git a/libs/common/src/tools/generator/default-generator.service.ts b/libs/common/src/tools/generator/default-generator.service.ts index 7fd794472c3..cec8b75f0cb 100644 --- a/libs/common/src/tools/generator/default-generator.service.ts +++ b/libs/common/src/tools/generator/default-generator.service.ts @@ -7,6 +7,13 @@ import { UserId } from "../../types/guid"; import { GeneratorStrategy, GeneratorService, PolicyEvaluator } from "./abstractions"; +type DefaultGeneratorServiceTuning = { + /* amount of time to keep the most recent policy after a subscription ends. Once the + * cache expires, the ignoreQty and timeoutMs settings apply to the next lookup. + */ + policyCacheMs: number; +}; + /** {@link GeneratorServiceAbstraction} */ export class DefaultGeneratorService implements GeneratorService { /** Instantiates the generator service @@ -17,8 +24,18 @@ export class DefaultGeneratorService implements GeneratorServic constructor( private strategy: GeneratorStrategy, private policy: PolicyService, - ) {} + tuning: Partial = {}, + ) { + this.tuning = Object.assign( + { + // a minute + policyCacheMs: 60000, + }, + tuning, + ); + } + private tuning: DefaultGeneratorServiceTuning; private _evaluators$ = new Map>>(); /** {@link GeneratorService.options$} */ @@ -57,7 +74,7 @@ export class DefaultGeneratorService implements GeneratorServic // and reduce GC pressure. share({ connector: () => new ReplaySubject(1), - resetOnRefCountZero: () => timer(this.strategy.cache_ms), + resetOnRefCountZero: () => timer(this.tuning.policyCacheMs), }), ); diff --git a/libs/common/src/tools/generator/history/legacy-password-history-decryptor.ts b/libs/common/src/tools/generator/history/legacy-password-history-decryptor.ts new file mode 100644 index 00000000000..6c59ca837cd --- /dev/null +++ b/libs/common/src/tools/generator/history/legacy-password-history-decryptor.ts @@ -0,0 +1,29 @@ +import { CryptoService } from "../../../platform/abstractions/crypto.service"; +import { EncryptService } from "../../../platform/abstractions/encrypt.service"; +import { EncString } from "../../../platform/models/domain/enc-string"; +import { UserId } from "../../../types/guid"; +import { GeneratedPasswordHistory } from "../password/generated-password-history"; + +/** Strategy that decrypts a password history */ +export class LegacyPasswordHistoryDecryptor { + constructor( + private userId: UserId, + private cryptoService: CryptoService, + private encryptService: EncryptService, + ) {} + + /** Decrypts a password history. */ + async decrypt(history: GeneratedPasswordHistory[]): Promise { + const key = await this.cryptoService.getUserKey(this.userId); + + const promises = (history ?? []).map(async (item) => { + const encrypted = new EncString(item.password); + const decrypted = await this.encryptService.decryptToUtf8(encrypted, key); + return new GeneratedPasswordHistory(decrypted, item.date); + }); + + const decrypted = await Promise.all(promises); + + return decrypted; + } +} diff --git a/libs/common/src/tools/generator/history/local-generator-history.service.spec.ts b/libs/common/src/tools/generator/history/local-generator-history.service.spec.ts index 57dde51fc13..9640016584a 100644 --- a/libs/common/src/tools/generator/history/local-generator-history.service.spec.ts +++ b/libs/common/src/tools/generator/history/local-generator-history.service.spec.ts @@ -1,5 +1,5 @@ import { mock } from "jest-mock-extended"; -import { firstValueFrom } from "rxjs"; +import { firstValueFrom, of } from "rxjs"; import { FakeStateProvider, awaitAsync, mockAccountServiceWith } from "../../../../spec"; import { CryptoService } from "../../../platform/abstractions/crypto.service"; @@ -24,6 +24,7 @@ describe("LocalGeneratorHistoryService", () => { encryptService.encrypt.mockImplementation((p) => Promise.resolve(p as unknown as EncString)); encryptService.decryptToUtf8.mockImplementation((c) => Promise.resolve(c.encryptedString)); keyService.getUserKey.mockImplementation(() => Promise.resolve(userKey)); + keyService.getInMemoryUserKeyFor$.mockImplementation(() => of(true as unknown as UserKey)); }); afterEach(() => { diff --git a/libs/common/src/tools/generator/history/local-generator-history.service.ts b/libs/common/src/tools/generator/history/local-generator-history.service.ts index 3a65890c50d..dd93e630cab 100644 --- a/libs/common/src/tools/generator/history/local-generator-history.service.ts +++ b/libs/common/src/tools/generator/history/local-generator-history.service.ts @@ -5,12 +5,14 @@ import { EncryptService } from "../../../platform/abstractions/encrypt.service"; import { SingleUserState, StateProvider } from "../../../platform/state"; import { UserId } from "../../../types/guid"; import { GeneratorHistoryService } from "../abstractions/generator-history.abstraction"; -import { GENERATOR_HISTORY } from "../key-definitions"; +import { GENERATOR_HISTORY, GENERATOR_HISTORY_BUFFER } from "../key-definitions"; +import { BufferedState } from "../state/buffered-state"; import { PaddedDataPacker } from "../state/padded-data-packer"; import { SecretState } from "../state/secret-state"; import { UserKeyEncryptor } from "../state/user-key-encryptor"; import { GeneratedCredential } from "./generated-credential"; +import { LegacyPasswordHistoryDecryptor } from "./legacy-password-history-decryptor"; import { GeneratorCategory, HistoryServiceOptions } from "./options"; const OPTIONS_FRAME_SIZE = 2048; @@ -51,7 +53,7 @@ export class LocalGeneratorHistoryService extends GeneratorHistoryService { }, { shouldUpdate: (credentials) => - credentials?.some((f) => f.credential !== credential) ?? true, + !(credentials?.some((f) => f.credential === credential) ?? false), }, ); @@ -82,6 +84,13 @@ export class LocalGeneratorHistoryService extends GeneratorHistoryService { return result; }; + /** {@link GeneratorHistoryService.take} */ + clear = async (userId: UserId) => { + const state = this.getCredentialState(userId); + const result = (await state.update(() => null)) ?? []; + return result; + }; + /** {@link GeneratorHistoryService.credentials$} */ credentials$ = (userId: UserId) => { return this.getCredentialState(userId).state$.pipe(map((credentials) => credentials ?? [])); @@ -98,11 +107,12 @@ export class LocalGeneratorHistoryService extends GeneratorHistoryService { return state; } - private createSecretState(userId: UserId) { + private createSecretState(userId: UserId): SingleUserState { // construct the encryptor const packer = new PaddedDataPacker(OPTIONS_FRAME_SIZE); const encryptor = new UserKeyEncryptor(this.encryptService, this.keyService, packer); + // construct the durable state const state = SecretState.from< GeneratedCredential[], number, @@ -111,6 +121,25 @@ export class LocalGeneratorHistoryService extends GeneratorHistoryService { GeneratedCredential >(userId, GENERATOR_HISTORY, this.stateProvider, encryptor); - return state; + // decryptor is just an algorithm, but it can't run until the key is available; + // providing it via an observable makes running it early impossible + const decryptor = new LegacyPasswordHistoryDecryptor( + userId, + this.keyService, + this.encryptService, + ); + const decryptor$ = this.keyService + .getInMemoryUserKeyFor$(userId) + .pipe(map((key) => key && decryptor)); + + // move data from the old password history once decryptor is available + const buffer = new BufferedState( + this.stateProvider, + GENERATOR_HISTORY_BUFFER, + state, + decryptor$, + ); + + return buffer; } } diff --git a/libs/common/src/tools/generator/index.ts b/libs/common/src/tools/generator/index.ts index ae35c9ce0a1..9df054a502b 100644 --- a/libs/common/src/tools/generator/index.ts +++ b/libs/common/src/tools/generator/index.ts @@ -2,3 +2,5 @@ export * from "./abstractions/index"; export * from "./password/index"; export { DefaultGeneratorService } from "./default-generator.service"; +export { legacyPasswordGenerationServiceFactory } from "./legacy-password-generation.service"; +export { legacyUsernameGenerationServiceFactory } from "./legacy-username-generation.service"; diff --git a/libs/common/src/tools/generator/key-definition.spec.ts b/libs/common/src/tools/generator/key-definition.spec.ts index 9cbbc44e148..d4992af0b11 100644 --- a/libs/common/src/tools/generator/key-definition.spec.ts +++ b/libs/common/src/tools/generator/key-definition.spec.ts @@ -1,3 +1,7 @@ +import { mock } from "jest-mock-extended"; + +import { GeneratedCredential } from "./history"; +import { LegacyPasswordHistoryDecryptor } from "./history/legacy-password-history-decryptor"; import { EFF_USERNAME_SETTINGS, CATCHALL_SETTINGS, @@ -11,7 +15,15 @@ import { DUCK_DUCK_GO_FORWARDER, ADDY_IO_FORWARDER, GENERATOR_SETTINGS, + ADDY_IO_BUFFER, + DUCK_DUCK_GO_BUFFER, + FASTMAIL_BUFFER, + FIREFOX_RELAY_BUFFER, + FORWARD_EMAIL_BUFFER, + SIMPLE_LOGIN_BUFFER, + GENERATOR_HISTORY_BUFFER, } from "./key-definitions"; +import { GeneratedPasswordHistory } from "./password"; describe("Key definitions", () => { describe("GENERATOR_SETTINGS", () => { @@ -109,4 +121,121 @@ describe("Key definitions", () => { expect(result).toBe(value); }); }); + + describe("ADDY_IO_BUFFER", () => { + it("should pass through deserialization", () => { + const value: any = {}; + + const result = ADDY_IO_BUFFER.options.deserializer(value); + + expect(result).toBe(value); + }); + }); + + describe("DUCK_DUCK_GO_BUFFER", () => { + it("should pass through deserialization", () => { + const value: any = {}; + + const result = DUCK_DUCK_GO_BUFFER.options.deserializer(value); + + expect(result).toBe(value); + }); + }); + + describe("FASTMAIL_BUFFER", () => { + it("should pass through deserialization", () => { + const value: any = {}; + + const result = FASTMAIL_BUFFER.options.deserializer(value); + + expect(result).toBe(value); + }); + }); + + describe("FIREFOX_RELAY_BUFFER", () => { + it("should pass through deserialization", () => { + const value: any = {}; + + const result = FIREFOX_RELAY_BUFFER.options.deserializer(value); + + expect(result).toBe(value); + }); + }); + + describe("FORWARD_EMAIL_BUFFER", () => { + it("should pass through deserialization", () => { + const value: any = {}; + + const result = FORWARD_EMAIL_BUFFER.options.deserializer(value); + + expect(result).toBe(value); + }); + }); + + describe("SIMPLE_LOGIN_BUFFER", () => { + it("should pass through deserialization", () => { + const value: any = {}; + + const result = SIMPLE_LOGIN_BUFFER.options.deserializer(value); + + expect(result).toBe(value); + }); + }); + + describe("GENERATOR_HISTORY_BUFFER", () => { + describe("options.deserializer", () => { + it("should deserialize generated password history", () => { + const value: any = [{ password: "foo", date: 1 }]; + + const [result] = GENERATOR_HISTORY_BUFFER.options.deserializer(value); + + expect(result).toEqual(value[0]); + expect(result).toBeInstanceOf(GeneratedPasswordHistory); + }); + + it.each([[undefined], [null]])("should ignore nullish (= %p) history", (value: any) => { + const result = GENERATOR_HISTORY_BUFFER.options.deserializer(value); + + expect(result).toEqual(undefined); + }); + }); + + it("should map generated password history to generated credentials", async () => { + const value: any = [new GeneratedPasswordHistory("foo", 1)]; + const decryptor = mock({ + decrypt(value) { + return Promise.resolve(value); + }, + }); + + const [result] = await GENERATOR_HISTORY_BUFFER.map(value, decryptor); + + expect(result).toEqual({ + credential: "foo", + category: "password", + generationDate: new Date(1), + }); + expect(result).toBeInstanceOf(GeneratedCredential); + }); + + describe("isValid", () => { + it("should accept histories with at least one entry", async () => { + const value: any = [new GeneratedPasswordHistory("foo", 1)]; + const decryptor = {} as any; + + const result = await GENERATOR_HISTORY_BUFFER.isValid(value, decryptor); + + expect(result).toEqual(true); + }); + + it("should reject histories with no entries", async () => { + const value: any = []; + const decryptor = {} as any; + + const result = await GENERATOR_HISTORY_BUFFER.isValid(value, decryptor); + + expect(result).toEqual(false); + }); + }); + }); }); diff --git a/libs/common/src/tools/generator/key-definitions.ts b/libs/common/src/tools/generator/key-definitions.ts index 074df484682..1ce2ec8ad12 100644 --- a/libs/common/src/tools/generator/key-definitions.ts +++ b/libs/common/src/tools/generator/key-definitions.ts @@ -1,9 +1,14 @@ -import { GENERATOR_DISK, GENERATOR_MEMORY, UserKeyDefinition } from "../../platform/state"; +import { Jsonify } from "type-fest"; + +import { GENERATOR_DISK, UserKeyDefinition } from "../../platform/state"; import { GeneratedCredential } from "./history/generated-credential"; +import { LegacyPasswordHistoryDecryptor } from "./history/legacy-password-history-decryptor"; import { GeneratorNavigation } from "./navigation/generator-navigation"; import { PassphraseGenerationOptions } from "./passphrase/passphrase-generation-options"; +import { GeneratedPasswordHistory } from "./password/generated-password-history"; import { PasswordGenerationOptions } from "./password/password-generation-options"; +import { BufferedKeyDefinition } from "./state/buffered-key-definition"; import { SecretClassifier } from "./state/secret-classifier"; import { SecretKeyDefinition } from "./state/secret-key-definition"; import { CatchallGenerationOptions } from "./username/catchall-generator-options"; @@ -18,11 +23,11 @@ import { SubaddressGenerationOptions } from "./username/subaddress-generator-opt /** plaintext password generation options */ export const GENERATOR_SETTINGS = new UserKeyDefinition( - GENERATOR_MEMORY, + GENERATOR_DISK, "generatorSettings", { deserializer: (value) => value, - clearOn: ["lock", "logout"], + clearOn: ["logout"], }, ); @@ -136,6 +141,66 @@ export const SIMPLE_LOGIN_FORWARDER = new UserKeyDefinition( + GENERATOR_DISK, + "addyIoBuffer", + { + deserializer: (value) => value, + clearOn: ["logout"], + }, +); + +/** backing store configuration for {@link Forwarders.DuckDuckGo} */ +export const DUCK_DUCK_GO_BUFFER = new BufferedKeyDefinition( + GENERATOR_DISK, + "duckDuckGoBuffer", + { + deserializer: (value) => value, + clearOn: ["logout"], + }, +); + +/** backing store configuration for {@link Forwarders.FastMail} */ +export const FASTMAIL_BUFFER = new BufferedKeyDefinition( + GENERATOR_DISK, + "fastmailBuffer", + { + deserializer: (value) => value, + clearOn: ["logout"], + }, +); + +/** backing store configuration for {@link Forwarders.FireFoxRelay} */ +export const FIREFOX_RELAY_BUFFER = new BufferedKeyDefinition( + GENERATOR_DISK, + "firefoxRelayBuffer", + { + deserializer: (value) => value, + clearOn: ["logout"], + }, +); + +/** backing store configuration for {@link Forwarders.ForwardEmail} */ +export const FORWARD_EMAIL_BUFFER = new BufferedKeyDefinition( + GENERATOR_DISK, + "forwardEmailBuffer", + { + deserializer: (value) => value, + clearOn: ["logout"], + }, +); + +/** backing store configuration for {@link forwarders.SimpleLogin} */ +export const SIMPLE_LOGIN_BUFFER = new BufferedKeyDefinition( + GENERATOR_DISK, + "simpleLoginBuffer", + { + deserializer: (value) => value, + clearOn: ["logout"], + }, +); + /** encrypted password generation history */ export const GENERATOR_HISTORY = SecretKeyDefinition.array( GENERATOR_DISK, @@ -146,3 +211,24 @@ export const GENERATOR_HISTORY = SecretKeyDefinition.array( clearOn: ["logout"], }, ); + +/** encrypted password generation history subject to migration */ +export const GENERATOR_HISTORY_BUFFER = new BufferedKeyDefinition< + GeneratedPasswordHistory[], + GeneratedCredential[], + LegacyPasswordHistoryDecryptor +>(GENERATOR_DISK, "localGeneratorHistoryBuffer", { + deserializer(history) { + const items = history as Jsonify[]; + return items?.map((h) => new GeneratedPasswordHistory(h.password, h.date)); + }, + async isValid(history) { + return history.length ? true : false; + }, + async map(history, decryptor) { + const credentials = await decryptor.decrypt(history); + const mapped = credentials.map((c) => new GeneratedCredential(c.password, "password", c.date)); + return mapped; + }, + clearOn: ["logout"], +}); diff --git a/libs/common/src/tools/generator/legacy-password-generation.service.spec.ts b/libs/common/src/tools/generator/legacy-password-generation.service.spec.ts index 093c68b3e83..c86bb9f8b04 100644 --- a/libs/common/src/tools/generator/legacy-password-generation.service.spec.ts +++ b/libs/common/src/tools/generator/legacy-password-generation.service.spec.ts @@ -8,7 +8,12 @@ import { of } from "rxjs"; import { mockAccountServiceWith } from "../../../spec"; import { UserId } from "../../types/guid"; -import { GeneratorNavigationService, GeneratorService } from "./abstractions"; +import { + GeneratorHistoryService, + GeneratorNavigationService, + GeneratorService, +} from "./abstractions"; +import { GeneratedCredential } from "./history"; import { LegacyPasswordGenerationService } from "./legacy-password-generation.service"; import { DefaultGeneratorNavigation, GeneratorNavigation } from "./navigation/generator-navigation"; import { GeneratorNavigationEvaluator } from "./navigation/generator-navigation-evaluator"; @@ -22,6 +27,7 @@ import { import { DisabledPassphraseGeneratorPolicy } from "./passphrase/passphrase-generator-policy"; import { DefaultPasswordGenerationOptions, + GeneratedPasswordHistory, PasswordGenerationOptions, PasswordGeneratorOptions, PasswordGeneratorOptionsEvaluator, @@ -97,10 +103,10 @@ function createNavigationGenerator( defaults$(id: UserId) { return of(DefaultGeneratorNavigation); }, - saveOptions(userId, options) { + saveOptions: jest.fn((userId, options) => { savedOptions = options; return Promise.resolve(); - }, + }), }); return generator; @@ -113,7 +119,7 @@ describe("LegacyPasswordGenerationService", () => { describe("generatePassword", () => { it("invokes the inner password generator to generate passwords", async () => { const innerPassword = createPasswordGenerator(); - const generator = new LegacyPasswordGenerationService(null, null, innerPassword, null); + const generator = new LegacyPasswordGenerationService(null, null, innerPassword, null, null); const options = { type: "password" } as PasswordGeneratorOptions; await generator.generatePassword(options); @@ -123,7 +129,13 @@ describe("LegacyPasswordGenerationService", () => { it("invokes the inner passphrase generator to generate passphrases", async () => { const innerPassphrase = createPassphraseGenerator(); - const generator = new LegacyPasswordGenerationService(null, null, null, innerPassphrase); + const generator = new LegacyPasswordGenerationService( + null, + null, + null, + innerPassphrase, + null, + ); const options = { type: "passphrase" } as PasswordGeneratorOptions; await generator.generatePassword(options); @@ -135,7 +147,13 @@ describe("LegacyPasswordGenerationService", () => { describe("generatePassphrase", () => { it("invokes the inner passphrase generator", async () => { const innerPassphrase = createPassphraseGenerator(); - const generator = new LegacyPasswordGenerationService(null, null, null, innerPassphrase); + const generator = new LegacyPasswordGenerationService( + null, + null, + null, + innerPassphrase, + null, + ); const options = {} as PasswordGeneratorOptions; await generator.generatePassphrase(options); @@ -157,7 +175,7 @@ describe("LegacyPasswordGenerationService", () => { number: true, minNumber: 3, special: false, - minSpecial: 4, + minSpecial: 0, }); const innerPassphrase = createPassphraseGenerator({ numWords: 10, @@ -176,29 +194,29 @@ describe("LegacyPasswordGenerationService", () => { navigation, innerPassword, innerPassphrase, + null, ); const [result] = await generator.getOptions(); expect(result).toEqual({ type: "passphrase", - username: "word", - forwarder: "simplelogin", length: 29, - minLength: 20, + minLength: 5, ambiguous: false, uppercase: true, minUppercase: 1, lowercase: false, - minLowercase: 2, + minLowercase: 0, number: true, minNumber: 3, special: false, - minSpecial: 4, + minSpecial: 0, numWords: 10, wordSeparator: "-", capitalize: true, includeNumber: false, + policyUpdated: true, }); }); @@ -212,14 +230,18 @@ describe("LegacyPasswordGenerationService", () => { navigation, innerPassword, innerPassphrase, + null, ); const [result] = await generator.getOptions(); expect(result).toEqual({ - ...DefaultGeneratorNavigation, + type: DefaultGeneratorNavigation.type, ...DefaultPassphraseGenerationOptions, ...DefaultPasswordGenerationOptions, + minLowercase: 1, + minUppercase: 1, + policyUpdated: true, }); }); @@ -256,6 +278,7 @@ describe("LegacyPasswordGenerationService", () => { navigation, innerPassword, innerPassphrase, + null, ); const [, policy] = await generator.getOptions(); @@ -301,6 +324,7 @@ describe("LegacyPasswordGenerationService", () => { navigation, innerPassword, innerPassphrase, + null, ); const [result] = await generator.enforcePasswordGeneratorPoliciesOnOptions(options); @@ -340,6 +364,7 @@ describe("LegacyPasswordGenerationService", () => { navigation, innerPassword, innerPassphrase, + null, ); const [result] = await generator.enforcePasswordGeneratorPoliciesOnOptions(options); @@ -385,6 +410,7 @@ describe("LegacyPasswordGenerationService", () => { navigation, innerPassword, innerPassphrase, + null, ); const [, policy] = await generator.enforcePasswordGeneratorPoliciesOnOptions({}); @@ -416,22 +442,21 @@ describe("LegacyPasswordGenerationService", () => { navigation, innerPassword, innerPassphrase, + null, ); const options = { type: "password" as const, - username: "word" as const, - forwarder: "simplelogin" as const, length: 29, - minLength: 20, + minLength: 5, ambiguous: false, uppercase: true, minUppercase: 1, lowercase: false, - minLowercase: 2, + minLowercase: 0, number: true, minNumber: 3, special: false, - minSpecial: 4, + minSpecial: 0, }; await generator.saveOptions(options); @@ -450,11 +475,10 @@ describe("LegacyPasswordGenerationService", () => { navigation, innerPassword, innerPassphrase, + null, ); const options = { type: "passphrase" as const, - username: "word" as const, - forwarder: "simplelogin" as const, numWords: 10, wordSeparator: "-", capitalize: true, @@ -466,5 +490,78 @@ describe("LegacyPasswordGenerationService", () => { expect(result).toMatchObject(options); }); + + it("preserves saved navigation options", async () => { + const innerPassword = createPasswordGenerator(); + const innerPassphrase = createPassphraseGenerator(); + const navigation = createNavigationGenerator({ + type: "password", + username: "forwarded", + forwarder: "firefoxrelay", + }); + const accountService = mockAccountServiceWith(SomeUser); + const generator = new LegacyPasswordGenerationService( + accountService, + navigation, + innerPassword, + innerPassphrase, + null, + ); + const options = { + type: "passphrase" as const, + numWords: 10, + wordSeparator: "-", + capitalize: true, + includeNumber: false, + }; + + await generator.saveOptions(options); + + expect(navigation.saveOptions).toHaveBeenCalledWith(SomeUser, { + type: "passphrase", + username: "forwarded", + forwarder: "firefoxrelay", + }); + }); + }); + + describe("getHistory", () => { + it("gets the active user's history from the history service", async () => { + const history = mock(); + history.credentials$.mockReturnValue( + of([new GeneratedCredential("foo", "password", new Date(100))]), + ); + const accountService = mockAccountServiceWith(SomeUser); + const generator = new LegacyPasswordGenerationService( + accountService, + null, + null, + null, + history, + ); + + const result = await generator.getHistory(); + + expect(history.credentials$).toHaveBeenCalledWith(SomeUser); + expect(result).toEqual([new GeneratedPasswordHistory("foo", 100)]); + }); + }); + + describe("addHistory", () => { + it("adds a history item as a password credential", async () => { + const history = mock(); + const accountService = mockAccountServiceWith(SomeUser); + const generator = new LegacyPasswordGenerationService( + accountService, + null, + null, + null, + history, + ); + + await generator.addHistory("foo"); + + expect(history.track).toHaveBeenCalledWith(SomeUser, "foo", "password"); + }); }); }); diff --git a/libs/common/src/tools/generator/legacy-password-generation.service.ts b/libs/common/src/tools/generator/legacy-password-generation.service.ts index 0b429b356bc..74b2ab46e6b 100644 --- a/libs/common/src/tools/generator/legacy-password-generation.service.ts +++ b/libs/common/src/tools/generator/legacy-password-generation.service.ts @@ -1,21 +1,42 @@ -import { concatMap, zip, map, firstValueFrom } from "rxjs"; +import { + concatMap, + zip, + map, + firstValueFrom, + combineLatest, + pairwise, + of, + concat, + Observable, +} from "rxjs"; import { PolicyService } from "../../admin-console/abstractions/policy/policy.service.abstraction"; import { PasswordGeneratorPolicyOptions } from "../../admin-console/models/domain/password-generator-policy-options"; import { AccountService } from "../../auth/abstractions/account.service"; import { CryptoService } from "../../platform/abstractions/crypto.service"; +import { EncryptService } from "../../platform/abstractions/encrypt.service"; import { StateProvider } from "../../platform/state"; -import { GeneratorService, GeneratorNavigationService } from "./abstractions"; +import { + GeneratorHistoryService, + GeneratorService, + GeneratorNavigationService, + PolicyEvaluator, +} from "./abstractions"; import { PasswordGenerationServiceAbstraction } from "./abstractions/password-generation.service.abstraction"; import { DefaultGeneratorService } from "./default-generator.service"; +import { GeneratedCredential } from "./history"; +import { LocalGeneratorHistoryService } from "./history/local-generator-history.service"; +import { GeneratorNavigation } from "./navigation"; import { DefaultGeneratorNavigationService } from "./navigation/default-generator-navigation.service"; +import { GeneratorNavigationPolicy } from "./navigation/generator-navigation-policy"; import { PassphraseGenerationOptions, PassphraseGeneratorPolicy, PassphraseGeneratorStrategy, } from "./passphrase"; import { + GeneratedPasswordHistory, PasswordGenerationOptions, PasswordGenerationService, PasswordGeneratorOptions, @@ -23,7 +44,15 @@ import { PasswordGeneratorStrategy, } from "./password"; +type MappedOptions = { + generator: GeneratorNavigation; + password: PasswordGenerationOptions; + passphrase: PassphraseGenerationOptions; + policyUpdated: boolean; +}; + export function legacyPasswordGenerationServiceFactory( + encryptService: EncryptService, cryptoService: CryptoService, policyService: PolicyService, accountService: AccountService, @@ -45,7 +74,15 @@ export function legacyPasswordGenerationServiceFactory( const navigation = new DefaultGeneratorNavigationService(stateProvider, policyService); - return new LegacyPasswordGenerationService(accountService, navigation, passwords, passphrases); + const history = new LocalGeneratorHistoryService(encryptService, cryptoService, stateProvider); + + return new LegacyPasswordGenerationService( + accountService, + navigation, + passwords, + passphrases, + history, + ); } /** Adapts the generator 2.0 design to 1.0 angular services. */ @@ -61,6 +98,7 @@ export class LegacyPasswordGenerationService implements PasswordGenerationServic PassphraseGenerationOptions, PassphraseGeneratorPolicy >, + private readonly history: GeneratorHistoryService, ) {} generatePassword(options: PasswordGeneratorOptions) { @@ -75,21 +113,112 @@ export class LegacyPasswordGenerationService implements PasswordGenerationServic return this.passphrases.generate(options); } - async getOptions() { - const options$ = this.accountService.activeAccount$.pipe( + private getRawOptions$() { + // give the typechecker a nudge to avoid "implicit any" errors + type RawOptionsIntermediateType = [ + PasswordGenerationOptions, + PasswordGenerationOptions, + [PolicyEvaluator, number], + PassphraseGenerationOptions, + PassphraseGenerationOptions, + [PolicyEvaluator, number], + GeneratorNavigation, + GeneratorNavigation, + [PolicyEvaluator, number], + ]; + + function withSequenceNumber(observable$: Observable) { + return observable$.pipe(map((evaluator, i) => [evaluator, i] as const)); + } + + // initial array ensures that destructuring never fails; sequence numbers + // set to `-1` so that the first update reflects that the policy changed from + // "unknown" to "whatever was provided by the service". This needs to be called + // each time the active user changes or the `concat` will block. + function initial$() { + const initial: RawOptionsIntermediateType = [ + null, + null, + [null, -1], + null, + null, + [null, -1], + null, + null, + [null, -1], + ]; + + return of(initial); + } + + function intermediatePairsToRawOptions([previous, current]: [ + RawOptionsIntermediateType, + RawOptionsIntermediateType, + ]) { + const [, , [, passwordPrevious], , , [, passphrasePrevious], , , [, generatorPrevious]] = + previous; + const [ + passwordOptions, + passwordDefaults, + [passwordEvaluator, passwordCurrent], + passphraseOptions, + passphraseDefaults, + [passphraseEvaluator, passphraseCurrent], + generatorOptions, + generatorDefaults, + [generatorEvaluator, generatorCurrent], + ] = current; + + // when any of the sequence numbers change, the emission occurs as the result of + // a policy update + const policyEmitted = + passwordPrevious < passwordCurrent || + passphrasePrevious < passphraseCurrent || + generatorPrevious < generatorCurrent; + + const result = [ + passwordOptions, + passwordDefaults, + passwordEvaluator, + passphraseOptions, + passphraseDefaults, + passphraseEvaluator, + generatorOptions, + generatorDefaults, + generatorEvaluator, + policyEmitted, + ] as const; + + return result; + } + + // look upon my works, ye mighty, and despair! + const rawOptions$ = this.accountService.activeAccount$.pipe( concatMap((activeUser) => - zip( - this.passwords.options$(activeUser.id), - this.passwords.defaults$(activeUser.id), - this.passwords.evaluator$(activeUser.id), - this.passphrases.options$(activeUser.id), - this.passphrases.defaults$(activeUser.id), - this.passphrases.evaluator$(activeUser.id), - this.navigation.options$(activeUser.id), - this.navigation.defaults$(activeUser.id), - this.navigation.evaluator$(activeUser.id), + concat( + initial$(), + combineLatest([ + this.passwords.options$(activeUser.id), + this.passwords.defaults$(activeUser.id), + withSequenceNumber(this.passwords.evaluator$(activeUser.id)), + this.passphrases.options$(activeUser.id), + this.passphrases.defaults$(activeUser.id), + withSequenceNumber(this.passphrases.evaluator$(activeUser.id)), + this.navigation.options$(activeUser.id), + this.navigation.defaults$(activeUser.id), + withSequenceNumber(this.navigation.evaluator$(activeUser.id)), + ]), ), ), + pairwise(), + map(intermediatePairsToRawOptions), + ); + + return rawOptions$; + } + + getOptions$() { + const options$ = this.getRawOptions$().pipe( map( ([ passwordOptions, @@ -101,14 +230,25 @@ export class LegacyPasswordGenerationService implements PasswordGenerationServic generatorOptions, generatorDefaults, generatorEvaluator, + policyUpdated, ]) => { - const options: PasswordGeneratorOptions = Object.assign( - {}, + const passwordOptionsWithPolicy = passwordEvaluator.applyPolicy( passwordOptions ?? passwordDefaults, + ); + const passphraseOptionsWithPolicy = passphraseEvaluator.applyPolicy( passphraseOptions ?? passphraseDefaults, + ); + const generatorOptionsWithPolicy = generatorEvaluator.applyPolicy( generatorOptions ?? generatorDefaults, ); + const options = this.toPasswordGeneratorOptions({ + password: passwordEvaluator.sanitize(passwordOptionsWithPolicy), + passphrase: passphraseEvaluator.sanitize(passphraseOptionsWithPolicy), + generator: generatorEvaluator.sanitize(generatorOptionsWithPolicy), + policyUpdated, + }); + const policy = Object.assign( new PasswordGeneratorPolicyOptions(), passwordEvaluator.policy, @@ -116,13 +256,16 @@ export class LegacyPasswordGenerationService implements PasswordGenerationServic generatorEvaluator.policy, ); - return [options, policy] as [PasswordGenerationOptions, PasswordGeneratorPolicyOptions]; + return [options, policy] as [PasswordGeneratorOptions, PasswordGeneratorPolicyOptions]; }, ), ); - const options = await firstValueFrom(options$); - return options; + return options$; + } + + async getOptions() { + return await firstValueFrom(this.getOptions$()); } async enforcePasswordGeneratorPoliciesOnOptions(options: PasswordGeneratorOptions) { @@ -164,21 +307,103 @@ export class LegacyPasswordGenerationService implements PasswordGenerationServic // callers assume this function updates the options parameter Object.assign(options, sanitized), policy, - ] as [PasswordGenerationOptions, PasswordGeneratorPolicyOptions]; + ] as [PasswordGeneratorOptions, PasswordGeneratorPolicyOptions]; } async saveOptions(options: PasswordGeneratorOptions) { + const stored = this.toStoredOptions(options); const activeAccount = await firstValueFrom(this.accountService.activeAccount$); - await this.navigation.saveOptions(activeAccount.id, options); - if (options.type === "password") { - await this.passwords.saveOptions(activeAccount.id, options); - } else { - await this.passphrases.saveOptions(activeAccount.id, options); - } + // generator settings needs to preserve whether password or passphrase is selected, + // so `navigationOptions` is mutated. + const navigationOptions$ = zip( + this.navigation.options$(activeAccount.id), + this.navigation.defaults$(activeAccount.id), + ).pipe(map(([options, defaults]) => options ?? defaults)); + let navigationOptions = await firstValueFrom(navigationOptions$); + navigationOptions = Object.assign(navigationOptions, stored.generator); + await this.navigation.saveOptions(activeAccount.id, navigationOptions); + + // overwrite all other settings with latest values + await this.passwords.saveOptions(activeAccount.id, stored.password); + await this.passphrases.saveOptions(activeAccount.id, stored.passphrase); } - getHistory: () => Promise; - addHistory: (password: string) => Promise; - clear: (userId?: string) => Promise; + private toStoredOptions(options: PasswordGeneratorOptions): MappedOptions { + return { + generator: { + type: options.type, + }, + password: { + length: options.length, + minLength: options.minLength, + ambiguous: options.ambiguous, + uppercase: options.uppercase, + minUppercase: options.minUppercase, + lowercase: options.lowercase, + minLowercase: options.minLowercase, + number: options.number, + minNumber: options.minNumber, + special: options.special, + minSpecial: options.minSpecial, + }, + passphrase: { + numWords: options.numWords, + wordSeparator: options.wordSeparator, + capitalize: options.capitalize, + includeNumber: options.includeNumber, + }, + policyUpdated: false, + }; + } + + private toPasswordGeneratorOptions(options: MappedOptions): PasswordGeneratorOptions { + return { + type: options.generator.type, + length: options.password.length, + minLength: options.password.minLength, + ambiguous: options.password.ambiguous, + uppercase: options.password.uppercase, + minUppercase: options.password.minUppercase, + lowercase: options.password.lowercase, + minLowercase: options.password.minLowercase, + number: options.password.number, + minNumber: options.password.minNumber, + special: options.password.special, + minSpecial: options.password.minSpecial, + numWords: options.passphrase.numWords, + wordSeparator: options.passphrase.wordSeparator, + capitalize: options.passphrase.capitalize, + includeNumber: options.passphrase.includeNumber, + policyUpdated: options.policyUpdated, + }; + } + + getHistory() { + const history = this.accountService.activeAccount$.pipe( + concatMap((account) => this.history.credentials$(account.id)), + map((history) => history.map(toGeneratedPasswordHistory)), + ); + + return firstValueFrom(history); + } + + async addHistory(password: string) { + const account = await firstValueFrom(this.accountService.activeAccount$); + // legacy service doesn't distinguish credential types + await this.history.track(account.id, password, "password"); + } + + clear() { + const history$ = this.accountService.activeAccount$.pipe( + concatMap((account) => this.history.clear(account.id)), + map((history) => history.map(toGeneratedPasswordHistory)), + ); + + return firstValueFrom(history$); + } +} + +function toGeneratedPasswordHistory(value: GeneratedCredential) { + return new GeneratedPasswordHistory(value.credential, value.generationDate.valueOf()); } diff --git a/libs/common/src/tools/generator/legacy-username-generation.service.spec.ts b/libs/common/src/tools/generator/legacy-username-generation.service.spec.ts index 41d9c78dd2b..8b5c8b81e5a 100644 --- a/libs/common/src/tools/generator/legacy-username-generation.service.spec.ts +++ b/libs/common/src/tools/generator/legacy-username-generation.service.spec.ts @@ -94,7 +94,7 @@ describe("LegacyUsernameGenerationService", () => { it("should generate a catchall username", async () => { const options = { type: "catchall" } as UsernameGeneratorOptions; const catchall = createGenerator(null, null); - catchall.generate.mockReturnValue(Promise.resolve("catchall@example.com")); + catchall.generate.mockResolvedValue("catchall@example.com"); const generator = new LegacyUsernameGenerationService( null, null, @@ -118,7 +118,7 @@ describe("LegacyUsernameGenerationService", () => { it("should generate an EFF word username", async () => { const options = { type: "word" } as UsernameGeneratorOptions; const effWord = createGenerator(null, null); - effWord.generate.mockReturnValue(Promise.resolve("eff word")); + effWord.generate.mockResolvedValue("eff word"); const generator = new LegacyUsernameGenerationService( null, null, @@ -142,7 +142,7 @@ describe("LegacyUsernameGenerationService", () => { it("should generate a subaddress username", async () => { const options = { type: "subaddress" } as UsernameGeneratorOptions; const subaddress = createGenerator(null, null); - subaddress.generate.mockReturnValue(Promise.resolve("subaddress@example.com")); + subaddress.generate.mockResolvedValue("subaddress@example.com"); const generator = new LegacyUsernameGenerationService( null, null, @@ -170,7 +170,7 @@ describe("LegacyUsernameGenerationService", () => { forwardedService: Forwarders.AddyIo.id, } as UsernameGeneratorOptions; const addyIo = createGenerator(null, null); - addyIo.generate.mockReturnValue(Promise.resolve("addyio@example.com")); + addyIo.generate.mockResolvedValue("addyio@example.com"); const generator = new LegacyUsernameGenerationService( null, null, @@ -196,7 +196,7 @@ describe("LegacyUsernameGenerationService", () => { it("should generate a catchall username", async () => { const options = { type: "catchall" } as UsernameGeneratorOptions; const catchall = createGenerator(null, null); - catchall.generate.mockReturnValue(Promise.resolve("catchall@example.com")); + catchall.generate.mockResolvedValue("catchall@example.com"); const generator = new LegacyUsernameGenerationService( null, null, @@ -222,7 +222,7 @@ describe("LegacyUsernameGenerationService", () => { it("should generate a subaddress username", async () => { const options = { type: "subaddress" } as UsernameGeneratorOptions; const subaddress = createGenerator(null, null); - subaddress.generate.mockReturnValue(Promise.resolve("subaddress@example.com")); + subaddress.generate.mockResolvedValue("subaddress@example.com"); const generator = new LegacyUsernameGenerationService( null, null, @@ -254,7 +254,7 @@ describe("LegacyUsernameGenerationService", () => { website: "example.com", } as UsernameGeneratorOptions; const addyIo = createGenerator(null, null); - addyIo.generate.mockReturnValue(Promise.resolve("addyio@example.com")); + addyIo.generate.mockResolvedValue("addyio@example.com"); const generator = new LegacyUsernameGenerationService( null, null, @@ -287,7 +287,7 @@ describe("LegacyUsernameGenerationService", () => { website: "example.com", } as UsernameGeneratorOptions; const duckDuckGo = createGenerator(null, null); - duckDuckGo.generate.mockReturnValue(Promise.resolve("ddg@example.com")); + duckDuckGo.generate.mockResolvedValue("ddg@example.com"); const generator = new LegacyUsernameGenerationService( null, null, @@ -318,7 +318,7 @@ describe("LegacyUsernameGenerationService", () => { website: "example.com", } as UsernameGeneratorOptions; const fastmail = createGenerator(null, null); - fastmail.generate.mockReturnValue(Promise.resolve("fastmail@example.com")); + fastmail.generate.mockResolvedValue("fastmail@example.com"); const generator = new LegacyUsernameGenerationService( null, null, @@ -349,7 +349,7 @@ describe("LegacyUsernameGenerationService", () => { website: "example.com", } as UsernameGeneratorOptions; const firefoxRelay = createGenerator(null, null); - firefoxRelay.generate.mockReturnValue(Promise.resolve("firefoxrelay@example.com")); + firefoxRelay.generate.mockResolvedValue("firefoxrelay@example.com"); const generator = new LegacyUsernameGenerationService( null, null, @@ -381,7 +381,7 @@ describe("LegacyUsernameGenerationService", () => { website: "example.com", } as UsernameGeneratorOptions; const forwardEmail = createGenerator(null, null); - forwardEmail.generate.mockReturnValue(Promise.resolve("forwardemail@example.com")); + forwardEmail.generate.mockResolvedValue("forwardemail@example.com"); const generator = new LegacyUsernameGenerationService( null, null, @@ -414,7 +414,7 @@ describe("LegacyUsernameGenerationService", () => { website: "example.com", } as UsernameGeneratorOptions; const simpleLogin = createGenerator(null, null); - simpleLogin.generate.mockReturnValue(Promise.resolve("simplelogin@example.com")); + simpleLogin.generate.mockResolvedValue("simplelogin@example.com"); const generator = new LegacyUsernameGenerationService( null, null, diff --git a/libs/common/src/tools/generator/legacy-username-generation.service.ts b/libs/common/src/tools/generator/legacy-username-generation.service.ts index 7611a86c274..61c19ee3144 100644 --- a/libs/common/src/tools/generator/legacy-username-generation.service.ts +++ b/libs/common/src/tools/generator/legacy-username-generation.service.ts @@ -1,4 +1,4 @@ -import { zip, firstValueFrom, map, concatMap } from "rxjs"; +import { zip, firstValueFrom, map, concatMap, combineLatest } from "rxjs"; import { ApiService } from "../../abstractions/api.service"; import { PolicyService } from "../../admin-console/abstractions/policy/policy.service.abstraction"; @@ -56,7 +56,7 @@ type MappedOptions = { }; }; -export function legacyPasswordGenerationServiceFactory( +export function legacyUsernameGenerationServiceFactory( apiService: ApiService, i18nService: I18nService, cryptoService: CryptoService, @@ -205,10 +205,11 @@ export class LegacyUsernameGenerationService implements UsernameGenerationServic } } - getOptions() { + getOptions$() { + // look upon my works, ye mighty, and despair! const options$ = this.accountService.activeAccount$.pipe( concatMap((account) => - zip( + combineLatest([ this.navigation.options$(account.id), this.navigation.defaults$(account.id), this.catchall.options$(account.id), @@ -229,7 +230,7 @@ export class LegacyUsernameGenerationService implements UsernameGenerationServic this.forwardEmail.defaults$(account.id), this.simpleLogin.options$(account.id), this.simpleLogin.defaults$(account.id), - ), + ]), ), map( ([ @@ -273,30 +274,38 @@ export class LegacyUsernameGenerationService implements UsernameGenerationServic ), ); - return firstValueFrom(options$); + return options$; + } + + getOptions() { + return firstValueFrom(this.getOptions$()); } async saveOptions(options: UsernameGeneratorOptions) { const stored = this.toStoredOptions(options); - const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(map((a) => a.id))); + const activeAccount = await firstValueFrom(this.accountService.activeAccount$); // generator settings needs to preserve whether password or passphrase is selected, // so `navigationOptions` is mutated. - let navigationOptions = await firstValueFrom(this.navigation.options$(userId)); + const navigationOptions$ = zip( + this.navigation.options$(activeAccount.id), + this.navigation.defaults$(activeAccount.id), + ).pipe(map(([options, defaults]) => options ?? defaults)); + let navigationOptions = await firstValueFrom(navigationOptions$); navigationOptions = Object.assign(navigationOptions, stored.generator); - await this.navigation.saveOptions(userId, navigationOptions); + await this.navigation.saveOptions(activeAccount.id, navigationOptions); // overwrite all other settings with latest values await Promise.all([ - this.catchall.saveOptions(userId, stored.algorithms.catchall), - this.effUsername.saveOptions(userId, stored.algorithms.effUsername), - this.subaddress.saveOptions(userId, stored.algorithms.subaddress), - this.addyIo.saveOptions(userId, stored.forwarders.addyIo), - this.duckDuckGo.saveOptions(userId, stored.forwarders.duckDuckGo), - this.fastmail.saveOptions(userId, stored.forwarders.fastmail), - this.firefoxRelay.saveOptions(userId, stored.forwarders.firefoxRelay), - this.forwardEmail.saveOptions(userId, stored.forwarders.forwardEmail), - this.simpleLogin.saveOptions(userId, stored.forwarders.simpleLogin), + this.catchall.saveOptions(activeAccount.id, stored.algorithms.catchall), + this.effUsername.saveOptions(activeAccount.id, stored.algorithms.effUsername), + this.subaddress.saveOptions(activeAccount.id, stored.algorithms.subaddress), + this.addyIo.saveOptions(activeAccount.id, stored.forwarders.addyIo), + this.duckDuckGo.saveOptions(activeAccount.id, stored.forwarders.duckDuckGo), + this.fastmail.saveOptions(activeAccount.id, stored.forwarders.fastmail), + this.firefoxRelay.saveOptions(activeAccount.id, stored.forwarders.firefoxRelay), + this.forwardEmail.saveOptions(activeAccount.id, stored.forwarders.forwardEmail), + this.simpleLogin.saveOptions(activeAccount.id, stored.forwarders.simpleLogin), ]); } diff --git a/libs/common/src/tools/generator/navigation/default-generator-navigation.service.ts b/libs/common/src/tools/generator/navigation/default-generator-navigation.service.ts index 3199efc8c3f..e5c259d841b 100644 --- a/libs/common/src/tools/generator/navigation/default-generator-navigation.service.ts +++ b/libs/common/src/tools/generator/navigation/default-generator-navigation.service.ts @@ -6,7 +6,7 @@ import { StateProvider } from "../../../platform/state"; import { UserId } from "../../../types/guid"; import { GeneratorNavigationService } from "../abstractions/generator-navigation.service.abstraction"; import { GENERATOR_SETTINGS } from "../key-definitions"; -import { reduceCollection } from "../reduce-collection.operator"; +import { distinctIfShallowMatch, reduceCollection } from "../rx-operators"; import { DefaultGeneratorNavigation, GeneratorNavigation } from "./generator-navigation"; import { GeneratorNavigationEvaluator } from "./generator-navigation-evaluator"; @@ -42,6 +42,7 @@ export class DefaultGeneratorNavigationService implements GeneratorNavigationSer evaluator$(userId: UserId) { const evaluator$ = this.policy.getAll$(PolicyType.PasswordGenerator, userId).pipe( reduceCollection(preferPassword, DisabledGeneratorNavigationPolicy), + distinctIfShallowMatch(), map((policy) => new GeneratorNavigationEvaluator(policy)), ); diff --git a/libs/common/src/tools/generator/passphrase/passphrase-generator-strategy.spec.ts b/libs/common/src/tools/generator/passphrase/passphrase-generator-strategy.spec.ts index adcfc395273..6ad1bd90dd4 100644 --- a/libs/common/src/tools/generator/passphrase/passphrase-generator-strategy.spec.ts +++ b/libs/common/src/tools/generator/passphrase/passphrase-generator-strategy.spec.ts @@ -84,15 +84,6 @@ describe("Password generation strategy", () => { }); }); - describe("cache_ms", () => { - it("should be a positive non-zero number", () => { - const legacy = mock(); - const strategy = new PassphraseGeneratorStrategy(legacy, null); - - expect(strategy.cache_ms).toBeGreaterThan(0); - }); - }); - describe("policy", () => { it("should use password generator policy", () => { const legacy = mock(); diff --git a/libs/common/src/tools/generator/passphrase/passphrase-generator-strategy.ts b/libs/common/src/tools/generator/passphrase/passphrase-generator-strategy.ts index 1a7c24082f3..c7b5ff8b787 100644 --- a/libs/common/src/tools/generator/passphrase/passphrase-generator-strategy.ts +++ b/libs/common/src/tools/generator/passphrase/passphrase-generator-strategy.ts @@ -6,7 +6,7 @@ import { StateProvider } from "../../../platform/state"; import { UserId } from "../../../types/guid"; import { PasswordGenerationServiceAbstraction } from "../abstractions/password-generation.service.abstraction"; import { PASSPHRASE_SETTINGS } from "../key-definitions"; -import { reduceCollection } from "../reduce-collection.operator"; +import { distinctIfShallowMatch, reduceCollection } from "../rx-operators"; import { PassphraseGenerationOptions, @@ -19,8 +19,6 @@ import { leastPrivilege, } from "./passphrase-generator-policy"; -const ONE_MINUTE = 60 * 1000; - /** {@link GeneratorStrategy} */ export class PassphraseGeneratorStrategy implements GeneratorStrategy @@ -49,15 +47,11 @@ export class PassphraseGeneratorStrategy return PolicyType.PasswordGenerator; } - /** {@link GeneratorStrategy.cache_ms} */ - get cache_ms() { - return ONE_MINUTE; - } - /** {@link GeneratorStrategy.toEvaluator} */ toEvaluator() { return pipe( reduceCollection(leastPrivilege, DisabledPassphraseGeneratorPolicy), + distinctIfShallowMatch(), map((policy) => new PassphraseGeneratorOptionsEvaluator(policy)), ); } diff --git a/libs/common/src/tools/generator/password/password-generation.service.ts b/libs/common/src/tools/generator/password/password-generation.service.ts index fced2dfe43f..e193b0fd33e 100644 --- a/libs/common/src/tools/generator/password/password-generation.service.ts +++ b/libs/common/src/tools/generator/password/password-generation.service.ts @@ -1,3 +1,5 @@ +import { from } from "rxjs"; + import { PolicyService } from "../../../admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyType } from "../../../admin-console/enums"; import { PasswordGeneratorPolicyOptions } from "../../../admin-console/models/domain/password-generator-policy-options"; @@ -171,6 +173,10 @@ export class PasswordGenerationService implements PasswordGenerationServiceAbstr return wordList.join(o.wordSeparator); } + getOptions$() { + return from(this.getOptions()); + } + async getOptions(): Promise<[PasswordGeneratorOptions, PasswordGeneratorPolicyOptions]> { let options = await this.stateService.getPasswordGenerationOptions(); if (options == null) { @@ -336,9 +342,10 @@ export class PasswordGenerationService implements PasswordGenerationServiceAbstr return await this.stateService.setEncryptedPasswordGenerationHistory(newHistory); } - async clear(userId?: string): Promise { + async clear(userId?: string): Promise { await this.stateService.setEncryptedPasswordGenerationHistory(null, { userId: userId }); await this.stateService.setDecryptedPasswordGenerationHistory(null, { userId: userId }); + return []; } private capitalize(str: string) { diff --git a/libs/common/src/tools/generator/password/password-generator-options.ts b/libs/common/src/tools/generator/password/password-generator-options.ts index aa0a6f7dabb..04a2f8c77a6 100644 --- a/libs/common/src/tools/generator/password/password-generator-options.ts +++ b/libs/common/src/tools/generator/password/password-generator-options.ts @@ -8,4 +8,4 @@ import { PasswordGenerationOptions } from "./password-generation-options"; */ export type PasswordGeneratorOptions = PasswordGenerationOptions & PassphraseGenerationOptions & - GeneratorNavigation; + GeneratorNavigation & { policyUpdated?: boolean }; diff --git a/libs/common/src/tools/generator/password/password-generator-strategy.spec.ts b/libs/common/src/tools/generator/password/password-generator-strategy.spec.ts index 5efc6a85a74..a7509e8b43e 100644 --- a/libs/common/src/tools/generator/password/password-generator-strategy.spec.ts +++ b/libs/common/src/tools/generator/password/password-generator-strategy.spec.ts @@ -93,15 +93,6 @@ describe("Password generation strategy", () => { }); }); - describe("cache_ms", () => { - it("should be a positive non-zero number", () => { - const legacy = mock(); - const strategy = new PasswordGeneratorStrategy(legacy, null); - - expect(strategy.cache_ms).toBeGreaterThan(0); - }); - }); - describe("policy", () => { it("should use password generator policy", () => { const legacy = mock(); diff --git a/libs/common/src/tools/generator/password/password-generator-strategy.ts b/libs/common/src/tools/generator/password/password-generator-strategy.ts index e98ae6fb161..23828d7b59d 100644 --- a/libs/common/src/tools/generator/password/password-generator-strategy.ts +++ b/libs/common/src/tools/generator/password/password-generator-strategy.ts @@ -6,7 +6,7 @@ import { StateProvider } from "../../../platform/state"; import { UserId } from "../../../types/guid"; import { PasswordGenerationServiceAbstraction } from "../abstractions/password-generation.service.abstraction"; import { PASSWORD_SETTINGS } from "../key-definitions"; -import { reduceCollection } from "../reduce-collection.operator"; +import { distinctIfShallowMatch, reduceCollection } from "../rx-operators"; import { DefaultPasswordGenerationOptions, @@ -19,8 +19,6 @@ import { leastPrivilege, } from "./password-generator-policy"; -const ONE_MINUTE = 60 * 1000; - /** {@link GeneratorStrategy} */ export class PasswordGeneratorStrategy implements GeneratorStrategy @@ -48,14 +46,11 @@ export class PasswordGeneratorStrategy return PolicyType.PasswordGenerator; } - get cache_ms() { - return ONE_MINUTE; - } - /** {@link GeneratorStrategy.toEvaluator} */ toEvaluator() { return pipe( reduceCollection(leastPrivilege, DisabledPasswordGeneratorPolicy), + distinctIfShallowMatch(), map((policy) => new PasswordGeneratorOptionsEvaluator(policy)), ); } diff --git a/libs/common/src/tools/generator/reduce-collection.operator.spec.ts b/libs/common/src/tools/generator/reduce-collection.operator.spec.ts deleted file mode 100644 index 49648dfdf00..00000000000 --- a/libs/common/src/tools/generator/reduce-collection.operator.spec.ts +++ /dev/null @@ -1,33 +0,0 @@ -/** - * include structuredClone in test environment. - * @jest-environment ../../../../shared/test.environment.ts - */ - -import { of, firstValueFrom } from "rxjs"; - -import { reduceCollection } from "./reduce-collection.operator"; - -describe("reduceCollection", () => { - it.each([[null], [undefined], [[]]])( - "should return the default value when the collection is %p", - async (value: number[]) => { - const reduce = (acc: number, value: number) => acc + value; - const source$ = of(value); - - const result$ = source$.pipe(reduceCollection(reduce, 100)); - const result = await firstValueFrom(result$); - - expect(result).toEqual(100); - }, - ); - - it("should reduce the collection to a single value", async () => { - const reduce = (acc: number, value: number) => acc + value; - const source$ = of([1, 2, 3]); - - const result$ = source$.pipe(reduceCollection(reduce, 0)); - const result = await firstValueFrom(result$); - - expect(result).toEqual(6); - }); -}); diff --git a/libs/common/src/tools/generator/rx-operators.spec.ts b/libs/common/src/tools/generator/rx-operators.spec.ts new file mode 100644 index 00000000000..3d7dd4530f7 --- /dev/null +++ b/libs/common/src/tools/generator/rx-operators.spec.ts @@ -0,0 +1,87 @@ +/** + * include structuredClone in test environment. + * @jest-environment ../../../../shared/test.environment.ts + */ + +import { of, firstValueFrom } from "rxjs"; + +import { awaitAsync, trackEmissions } from "../../../spec"; + +import { distinctIfShallowMatch, reduceCollection } from "./rx-operators"; + +describe("reduceCollection", () => { + it.each([[null], [undefined], [[]]])( + "should return the default value when the collection is %p", + async (value: number[]) => { + const reduce = (acc: number, value: number) => acc + value; + const source$ = of(value); + + const result$ = source$.pipe(reduceCollection(reduce, 100)); + const result = await firstValueFrom(result$); + + expect(result).toEqual(100); + }, + ); + + it("should reduce the collection to a single value", async () => { + const reduce = (acc: number, value: number) => acc + value; + const source$ = of([1, 2, 3]); + + const result$ = source$.pipe(reduceCollection(reduce, 0)); + const result = await firstValueFrom(result$); + + expect(result).toEqual(6); + }); +}); + +describe("distinctIfShallowMatch", () => { + it("emits a single value", async () => { + const source$ = of({ foo: true }); + const pipe$ = source$.pipe(distinctIfShallowMatch()); + + const result = trackEmissions(pipe$); + await awaitAsync(); + + expect(result).toEqual([{ foo: true }]); + }); + + it("emits different values", async () => { + const source$ = of({ foo: true }, { foo: false }); + const pipe$ = source$.pipe(distinctIfShallowMatch()); + + const result = trackEmissions(pipe$); + await awaitAsync(); + + expect(result).toEqual([{ foo: true }, { foo: false }]); + }); + + it("emits new keys", async () => { + const source$ = of({ foo: true }, { foo: true, bar: true }); + const pipe$ = source$.pipe(distinctIfShallowMatch()); + + const result = trackEmissions(pipe$); + await awaitAsync(); + + expect(result).toEqual([{ foo: true }, { foo: true, bar: true }]); + }); + + it("suppresses identical values", async () => { + const source$ = of({ foo: true }, { foo: true }); + const pipe$ = source$.pipe(distinctIfShallowMatch()); + + const result = trackEmissions(pipe$); + await awaitAsync(); + + expect(result).toEqual([{ foo: true }]); + }); + + it("suppresses removed keys", async () => { + const source$ = of({ foo: true, bar: true }, { foo: true }); + const pipe$ = source$.pipe(distinctIfShallowMatch()); + + const result = trackEmissions(pipe$); + await awaitAsync(); + + expect(result).toEqual([{ foo: true, bar: true }]); + }); +}); diff --git a/libs/common/src/tools/generator/reduce-collection.operator.ts b/libs/common/src/tools/generator/rx-operators.ts similarity index 55% rename from libs/common/src/tools/generator/reduce-collection.operator.ts rename to libs/common/src/tools/generator/rx-operators.ts index 224595eeba2..6524ef79941 100644 --- a/libs/common/src/tools/generator/reduce-collection.operator.ts +++ b/libs/common/src/tools/generator/rx-operators.ts @@ -1,4 +1,4 @@ -import { map, OperatorFunction } from "rxjs"; +import { distinctUntilChanged, map, OperatorFunction } from "rxjs"; /** * An observable operator that reduces an emitted collection to a single object, @@ -18,3 +18,21 @@ export function reduceCollection( return reduced; }); } + +/** + * An observable operator that emits distinct values by checking that all + * values in the previous entry match the next entry. This method emits + * when a key is added and does not when a key is removed. + * @remarks This method checks objects. It does not check items in arrays. + */ +export function distinctIfShallowMatch(): OperatorFunction { + return distinctUntilChanged((previous, current) => { + let isDistinct = true; + + for (const key in current) { + isDistinct &&= previous[key] === current[key]; + } + + return isDistinct; + }); +} diff --git a/libs/common/src/tools/generator/username/catchall-generator-strategy.spec.ts b/libs/common/src/tools/generator/username/catchall-generator-strategy.spec.ts index 52cfa00aaf1..30f11b1e89b 100644 --- a/libs/common/src/tools/generator/username/catchall-generator-strategy.spec.ts +++ b/libs/common/src/tools/generator/username/catchall-generator-strategy.spec.ts @@ -59,15 +59,6 @@ describe("Email subaddress list generation strategy", () => { }); }); - describe("cache_ms", () => { - it("should be a positive non-zero number", () => { - const legacy = mock(); - const strategy = new CatchallGeneratorStrategy(legacy, null); - - expect(strategy.cache_ms).toBeGreaterThan(0); - }); - }); - describe("policy", () => { it("should use password generator policy", () => { const legacy = mock(); diff --git a/libs/common/src/tools/generator/username/catchall-generator-strategy.ts b/libs/common/src/tools/generator/username/catchall-generator-strategy.ts index 5111b06e90c..ee86fd9fd66 100644 --- a/libs/common/src/tools/generator/username/catchall-generator-strategy.ts +++ b/libs/common/src/tools/generator/username/catchall-generator-strategy.ts @@ -11,8 +11,6 @@ import { NoPolicy } from "../no-policy"; import { CatchallGenerationOptions, DefaultCatchallOptions } from "./catchall-generator-options"; -const ONE_MINUTE = 60 * 1000; - /** Strategy for creating usernames using a catchall email address */ export class CatchallGeneratorStrategy implements GeneratorStrategy @@ -42,11 +40,6 @@ export class CatchallGeneratorStrategy return PolicyType.PasswordGenerator; } - /** {@link GeneratorStrategy.cache_ms} */ - get cache_ms() { - return ONE_MINUTE; - } - /** {@link GeneratorStrategy.toEvaluator} */ toEvaluator() { return pipe(map((_) => new DefaultPolicyEvaluator())); diff --git a/libs/common/src/tools/generator/username/eff-username-generator-strategy.spec.ts b/libs/common/src/tools/generator/username/eff-username-generator-strategy.spec.ts index 9b0e4cc0694..76e51f609cc 100644 --- a/libs/common/src/tools/generator/username/eff-username-generator-strategy.spec.ts +++ b/libs/common/src/tools/generator/username/eff-username-generator-strategy.spec.ts @@ -59,15 +59,6 @@ describe("EFF long word list generation strategy", () => { }); }); - describe("cache_ms", () => { - it("should be a positive non-zero number", () => { - const legacy = mock(); - const strategy = new EffUsernameGeneratorStrategy(legacy, null); - - expect(strategy.cache_ms).toBeGreaterThan(0); - }); - }); - describe("policy", () => { it("should use password generator policy", () => { const legacy = mock(); diff --git a/libs/common/src/tools/generator/username/eff-username-generator-strategy.ts b/libs/common/src/tools/generator/username/eff-username-generator-strategy.ts index 1a4efdcb44b..70d1f854205 100644 --- a/libs/common/src/tools/generator/username/eff-username-generator-strategy.ts +++ b/libs/common/src/tools/generator/username/eff-username-generator-strategy.ts @@ -14,8 +14,6 @@ import { EffUsernameGenerationOptions, } from "./eff-username-generator-options"; -const ONE_MINUTE = 60 * 1000; - /** Strategy for creating usernames from the EFF wordlist */ export class EffUsernameGeneratorStrategy implements GeneratorStrategy @@ -45,11 +43,6 @@ export class EffUsernameGeneratorStrategy return PolicyType.PasswordGenerator; } - /** {@link GeneratorStrategy.cache_ms} */ - get cache_ms() { - return ONE_MINUTE; - } - /** {@link GeneratorStrategy.toEvaluator} */ toEvaluator() { return pipe(map((_) => new DefaultPolicyEvaluator())); diff --git a/libs/common/src/tools/generator/username/forwarder-generator-strategy.spec.ts b/libs/common/src/tools/generator/username/forwarder-generator-strategy.spec.ts index c2a606eae0a..d3bec745f14 100644 --- a/libs/common/src/tools/generator/username/forwarder-generator-strategy.spec.ts +++ b/libs/common/src/tools/generator/username/forwarder-generator-strategy.spec.ts @@ -10,9 +10,10 @@ import { CryptoService } from "../../../platform/abstractions/crypto.service"; import { EncryptService } from "../../../platform/abstractions/encrypt.service"; import { StateProvider } from "../../../platform/state"; import { UserId } from "../../../types/guid"; +import { UserKey } from "../../../types/key"; import { DefaultPolicyEvaluator } from "../default-policy-evaluator"; -import { DUCK_DUCK_GO_FORWARDER } from "../key-definitions"; -import { SecretState } from "../state/secret-state"; +import { DUCK_DUCK_GO_FORWARDER, DUCK_DUCK_GO_BUFFER } from "../key-definitions"; +import { BufferedState } from "../state/buffered-state"; import { ForwarderGeneratorStrategy } from "./forwarder-generator-strategy"; import { DefaultDuckDuckGoOptions } from "./forwarders/duck-duck-go"; @@ -32,6 +33,10 @@ class TestForwarder extends ForwarderGeneratorStrategy { return DUCK_DUCK_GO_FORWARDER; } + get rolloverKey() { + return DUCK_DUCK_GO_BUFFER; + } + defaults$ = (userId: UserId) => { return of(DefaultDuckDuckGoOptions); }; @@ -51,13 +56,22 @@ describe("ForwarderGeneratorStrategy", () => { const keyService = mock(); const stateProvider = new FakeStateProvider(mockAccountServiceWith(SomeUser)); + beforeEach(() => { + const keyAvailable = of({} as UserKey); + keyService.getInMemoryUserKeyFor$.mockReturnValue(keyAvailable); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + describe("durableState", () => { it("constructs a secret state", () => { const strategy = new TestForwarder(encryptService, keyService, stateProvider); const result = strategy.durableState(SomeUser); - expect(result).toBeInstanceOf(SecretState); + expect(result).toBeInstanceOf(BufferedState); }); it("returns the same secret state for a single user", () => { diff --git a/libs/common/src/tools/generator/username/forwarder-generator-strategy.ts b/libs/common/src/tools/generator/username/forwarder-generator-strategy.ts index b4205b9fc98..1abefcc23c4 100644 --- a/libs/common/src/tools/generator/username/forwarder-generator-strategy.ts +++ b/libs/common/src/tools/generator/username/forwarder-generator-strategy.ts @@ -8,6 +8,8 @@ import { UserId } from "../../../types/guid"; import { GeneratorStrategy } from "../abstractions"; import { DefaultPolicyEvaluator } from "../default-policy-evaluator"; import { NoPolicy } from "../no-policy"; +import { BufferedKeyDefinition } from "../state/buffered-key-definition"; +import { BufferedState } from "../state/buffered-state"; import { PaddedDataPacker } from "../state/padded-data-packer"; import { SecretClassifier } from "../state/secret-classifier"; import { SecretKeyDefinition } from "../state/secret-key-definition"; @@ -16,7 +18,6 @@ import { UserKeyEncryptor } from "../state/user-key-encryptor"; import { ApiOptions } from "./options/forwarder-options"; -const ONE_MINUTE = 60 * 1000; const OPTIONS_FRAME_SIZE = 512; /** An email forwarding service configurable through an API. */ @@ -37,8 +38,6 @@ export abstract class ForwarderGeneratorStrategy< // Uses password generator since there aren't policies // specific to usernames. this.policy = PolicyType.PasswordGenerator; - - this.cache_ms = ONE_MINUTE; } private durableStates = new Map>(); @@ -48,25 +47,7 @@ export abstract class ForwarderGeneratorStrategy< let state = this.durableStates.get(userId); if (!state) { - const encryptor = this.createEncryptor(); - // always exclude request properties - const classifier = SecretClassifier.allSecret().exclude("website"); - - // Derive the secret key definition - const key = SecretKeyDefinition.value(this.key.stateDefinition, this.key.key, classifier, { - deserializer: (d) => this.key.deserializer(d), - cleanupDelayMs: this.key.cleanupDelayMs, - clearOn: this.key.clearOn, - }); - - // the type parameter is explicit because type inference fails for `Omit` - state = SecretState.from< - Options, - void, - Options, - Record, - Omit - >(userId, key, this.stateProvider, encryptor); + state = this.createState(userId); this.durableStates.set(userId, state); } @@ -74,10 +55,42 @@ export abstract class ForwarderGeneratorStrategy< return state; }; - private createEncryptor() { + private createState(userId: UserId): SingleUserState { // construct the encryptor const packer = new PaddedDataPacker(OPTIONS_FRAME_SIZE); - return new UserKeyEncryptor(this.encryptService, this.keyService, packer); + const encryptor = new UserKeyEncryptor(this.encryptService, this.keyService, packer); + + // always exclude request properties + const classifier = SecretClassifier.allSecret().exclude("website"); + + // Derive the secret key definition + const key = SecretKeyDefinition.value(this.key.stateDefinition, this.key.key, classifier, { + deserializer: (d) => this.key.deserializer(d), + cleanupDelayMs: this.key.cleanupDelayMs, + clearOn: this.key.clearOn, + }); + + // the type parameter is explicit because type inference fails for `Omit` + const secretState = SecretState.from< + Options, + void, + Options, + Record, + Omit + >(userId, key, this.stateProvider, encryptor); + + // rollover should occur once the user key is available for decryption + const canDecrypt$ = this.keyService + .getInMemoryUserKeyFor$(userId) + .pipe(map((key) => key !== null)); + const rolloverState = new BufferedState( + this.stateProvider, + this.rolloverKey, + secretState, + canDecrypt$, + ); + + return rolloverState; } /** Gets the default options. */ @@ -86,6 +99,9 @@ export abstract class ForwarderGeneratorStrategy< /** Determine where forwarder configuration is stored */ protected abstract readonly key: UserKeyDefinition; + /** Determine where forwarder rollover configuration is stored */ + protected abstract readonly rolloverKey: BufferedKeyDefinition; + /** {@link GeneratorStrategy.toEvaluator} */ toEvaluator = () => { return pipe(map((_) => new DefaultPolicyEvaluator())); diff --git a/libs/common/src/tools/generator/username/forwarders/addy-io.ts b/libs/common/src/tools/generator/username/forwarders/addy-io.ts index 3e4960f7e76..12121749519 100644 --- a/libs/common/src/tools/generator/username/forwarders/addy-io.ts +++ b/libs/common/src/tools/generator/username/forwarders/addy-io.ts @@ -6,7 +6,7 @@ import { EncryptService } from "../../../../platform/abstractions/encrypt.servic import { I18nService } from "../../../../platform/abstractions/i18n.service"; import { StateProvider } from "../../../../platform/state"; import { UserId } from "../../../../types/guid"; -import { ADDY_IO_FORWARDER } from "../../key-definitions"; +import { ADDY_IO_FORWARDER, ADDY_IO_BUFFER } from "../../key-definitions"; import { ForwarderGeneratorStrategy } from "../forwarder-generator-strategy"; import { Forwarders } from "../options/constants"; import { EmailDomainOptions, SelfHostedApiOptions } from "../options/forwarder-options"; @@ -44,6 +44,11 @@ export class AddyIoForwarder extends ForwarderGeneratorStrategy< return ADDY_IO_FORWARDER; } + /** {@link ForwarderGeneratorStrategy.rolloverKey} */ + get rolloverKey() { + return ADDY_IO_BUFFER; + } + /** {@link ForwarderGeneratorStrategy.defaults$} */ defaults$ = (userId: UserId) => { return new BehaviorSubject({ ...DefaultAddyIoOptions }); diff --git a/libs/common/src/tools/generator/username/forwarders/duck-duck-go.ts b/libs/common/src/tools/generator/username/forwarders/duck-duck-go.ts index 9b5d93d742e..4a9040d74a9 100644 --- a/libs/common/src/tools/generator/username/forwarders/duck-duck-go.ts +++ b/libs/common/src/tools/generator/username/forwarders/duck-duck-go.ts @@ -6,7 +6,7 @@ import { EncryptService } from "../../../../platform/abstractions/encrypt.servic import { I18nService } from "../../../../platform/abstractions/i18n.service"; import { StateProvider } from "../../../../platform/state"; import { UserId } from "../../../../types/guid"; -import { DUCK_DUCK_GO_FORWARDER } from "../../key-definitions"; +import { DUCK_DUCK_GO_FORWARDER, DUCK_DUCK_GO_BUFFER } from "../../key-definitions"; import { ForwarderGeneratorStrategy } from "../forwarder-generator-strategy"; import { Forwarders } from "../options/constants"; import { ApiOptions } from "../options/forwarder-options"; @@ -40,6 +40,11 @@ export class DuckDuckGoForwarder extends ForwarderGeneratorStrategy return DUCK_DUCK_GO_FORWARDER; } + /** {@link ForwarderGeneratorStrategy.rolloverKey} */ + get rolloverKey() { + return DUCK_DUCK_GO_BUFFER; + } + /** {@link ForwarderGeneratorStrategy.defaults$} */ defaults$ = (userId: UserId) => { return new BehaviorSubject({ ...DefaultDuckDuckGoOptions }); diff --git a/libs/common/src/tools/generator/username/forwarders/fastmail.ts b/libs/common/src/tools/generator/username/forwarders/fastmail.ts index 9d62cd0039d..e6ab28b59e3 100644 --- a/libs/common/src/tools/generator/username/forwarders/fastmail.ts +++ b/libs/common/src/tools/generator/username/forwarders/fastmail.ts @@ -6,7 +6,7 @@ import { EncryptService } from "../../../../platform/abstractions/encrypt.servic import { I18nService } from "../../../../platform/abstractions/i18n.service"; import { StateProvider } from "../../../../platform/state"; import { UserId } from "../../../../types/guid"; -import { FASTMAIL_FORWARDER } from "../../key-definitions"; +import { FASTMAIL_FORWARDER, FASTMAIL_BUFFER } from "../../key-definitions"; import { ForwarderGeneratorStrategy } from "../forwarder-generator-strategy"; import { Forwarders } from "../options/constants"; import { EmailPrefixOptions, ApiOptions } from "../options/forwarder-options"; @@ -47,6 +47,11 @@ export class FastmailForwarder extends ForwarderGeneratorStrategy { if (!options.token || options.token === "") { diff --git a/libs/common/src/tools/generator/username/forwarders/firefox-relay.ts b/libs/common/src/tools/generator/username/forwarders/firefox-relay.ts index a4122c53f8f..c381d3d3f2e 100644 --- a/libs/common/src/tools/generator/username/forwarders/firefox-relay.ts +++ b/libs/common/src/tools/generator/username/forwarders/firefox-relay.ts @@ -6,7 +6,7 @@ import { EncryptService } from "../../../../platform/abstractions/encrypt.servic import { I18nService } from "../../../../platform/abstractions/i18n.service"; import { StateProvider } from "../../../../platform/state"; import { UserId } from "../../../../types/guid"; -import { FIREFOX_RELAY_FORWARDER } from "../../key-definitions"; +import { FIREFOX_RELAY_FORWARDER, FIREFOX_RELAY_BUFFER } from "../../key-definitions"; import { ForwarderGeneratorStrategy } from "../forwarder-generator-strategy"; import { Forwarders } from "../options/constants"; import { ApiOptions } from "../options/forwarder-options"; @@ -40,6 +40,11 @@ export class FirefoxRelayForwarder extends ForwarderGeneratorStrategy { return new BehaviorSubject({ ...DefaultFirefoxRelayOptions }); diff --git a/libs/common/src/tools/generator/username/forwarders/forward-email.ts b/libs/common/src/tools/generator/username/forwarders/forward-email.ts index 93f4680414a..af654d3917e 100644 --- a/libs/common/src/tools/generator/username/forwarders/forward-email.ts +++ b/libs/common/src/tools/generator/username/forwarders/forward-email.ts @@ -7,7 +7,7 @@ import { I18nService } from "../../../../platform/abstractions/i18n.service"; import { Utils } from "../../../../platform/misc/utils"; import { StateProvider } from "../../../../platform/state"; import { UserId } from "../../../../types/guid"; -import { FORWARD_EMAIL_FORWARDER } from "../../key-definitions"; +import { FORWARD_EMAIL_FORWARDER, FORWARD_EMAIL_BUFFER } from "../../key-definitions"; import { ForwarderGeneratorStrategy } from "../forwarder-generator-strategy"; import { Forwarders } from "../options/constants"; import { EmailDomainOptions, ApiOptions } from "../options/forwarder-options"; @@ -49,6 +49,11 @@ export class ForwardEmailForwarder extends ForwarderGeneratorStrategy< return new BehaviorSubject({ ...DefaultForwardEmailOptions }); }; + /** {@link ForwarderGeneratorStrategy.rolloverKey} */ + get rolloverKey() { + return FORWARD_EMAIL_BUFFER; + } + /** {@link ForwarderGeneratorStrategy.generate} */ generate = async (options: ApiOptions & EmailDomainOptions) => { if (!options.token || options.token === "") { diff --git a/libs/common/src/tools/generator/username/forwarders/simple-login.ts b/libs/common/src/tools/generator/username/forwarders/simple-login.ts index d047fc42d14..ee91a411451 100644 --- a/libs/common/src/tools/generator/username/forwarders/simple-login.ts +++ b/libs/common/src/tools/generator/username/forwarders/simple-login.ts @@ -6,7 +6,7 @@ import { EncryptService } from "../../../../platform/abstractions/encrypt.servic import { I18nService } from "../../../../platform/abstractions/i18n.service"; import { StateProvider } from "../../../../platform/state"; import { UserId } from "../../../../types/guid"; -import { SIMPLE_LOGIN_FORWARDER } from "../../key-definitions"; +import { SIMPLE_LOGIN_FORWARDER, SIMPLE_LOGIN_BUFFER } from "../../key-definitions"; import { ForwarderGeneratorStrategy } from "../forwarder-generator-strategy"; import { Forwarders } from "../options/constants"; import { SelfHostedApiOptions } from "../options/forwarder-options"; @@ -41,6 +41,11 @@ export class SimpleLoginForwarder extends ForwarderGeneratorStrategy { return new BehaviorSubject({ ...DefaultSimpleLoginOptions }); diff --git a/libs/common/src/tools/generator/username/subaddress-generator-strategy.spec.ts b/libs/common/src/tools/generator/username/subaddress-generator-strategy.spec.ts index 827bc7aed0d..b5ac9c4cf9c 100644 --- a/libs/common/src/tools/generator/username/subaddress-generator-strategy.spec.ts +++ b/libs/common/src/tools/generator/username/subaddress-generator-strategy.spec.ts @@ -62,15 +62,6 @@ describe("Email subaddress list generation strategy", () => { }); }); - describe("cache_ms", () => { - it("should be a positive non-zero number", () => { - const legacy = mock(); - const strategy = new SubaddressGeneratorStrategy(legacy, null); - - expect(strategy.cache_ms).toBeGreaterThan(0); - }); - }); - describe("policy", () => { it("should use password generator policy", () => { const legacy = mock(); diff --git a/libs/common/src/tools/generator/username/subaddress-generator-strategy.ts b/libs/common/src/tools/generator/username/subaddress-generator-strategy.ts index 818741f8a9b..6106d6d476f 100644 --- a/libs/common/src/tools/generator/username/subaddress-generator-strategy.ts +++ b/libs/common/src/tools/generator/username/subaddress-generator-strategy.ts @@ -14,8 +14,6 @@ import { SubaddressGenerationOptions, } from "./subaddress-generator-options"; -const ONE_MINUTE = 60 * 1000; - /** Strategy for creating an email subaddress * @remarks The subaddress is the part following the `+`. * For example, if the email address is `jd+xyz@domain.io`, @@ -49,11 +47,6 @@ export class SubaddressGeneratorStrategy return PolicyType.PasswordGenerator; } - /** {@link GeneratorStrategy.cache_ms} */ - get cache_ms() { - return ONE_MINUTE; - } - /** {@link GeneratorStrategy.toEvaluator} */ toEvaluator() { return pipe(map((_) => new DefaultPolicyEvaluator())); diff --git a/libs/common/src/tools/generator/username/username-generation.service.ts b/libs/common/src/tools/generator/username/username-generation.service.ts index 1ee642da5eb..e659aacb51f 100644 --- a/libs/common/src/tools/generator/username/username-generation.service.ts +++ b/libs/common/src/tools/generator/username/username-generation.service.ts @@ -1,3 +1,5 @@ +import { from } from "rxjs"; + import { ApiService } from "../../../abstractions/api.service"; import { CryptoService } from "../../../platform/abstractions/crypto.service"; import { StateService } from "../../../platform/abstractions/state.service"; @@ -158,6 +160,10 @@ export class UsernameGenerationService implements UsernameGenerationServiceAbstr return forwarder.generate(this.apiService, forwarderOptions); } + getOptions$() { + return from(this.getOptions()); + } + async getOptions(): Promise { let options = await this.stateService.getUsernameGenerationOptions(); if (options == null) { diff --git a/libs/tools/export/vault-export/vault-export-ui/package.json b/libs/tools/export/vault-export/vault-export-ui/package.json index e27140f3657..3be54c4e191 100644 --- a/libs/tools/export/vault-export/vault-export-ui/package.json +++ b/libs/tools/export/vault-export/vault-export-ui/package.json @@ -20,6 +20,7 @@ "dependencies": { "@bitwarden/common": "file:../../../../common", "@bitwarden/angular": "file:../../../../angular", + "@bitwarden/auth": "file:../../../../auth", "@bitwarden/vault-export-core": "file:../vault-export-core" } } diff --git a/libs/tools/export/vault-export/vault-export-ui/src/components/export.component.ts b/libs/tools/export/vault-export/vault-export-ui/src/components/export.component.ts index ce478db19a7..3e091a2417a 100644 --- a/libs/tools/export/vault-export/vault-export-ui/src/components/export.component.ts +++ b/libs/tools/export/vault-export/vault-export-ui/src/components/export.component.ts @@ -3,12 +3,12 @@ import { UntypedFormBuilder, Validators } from "@angular/forms"; import { map, merge, Observable, startWith, Subject, takeUntil } from "rxjs"; import { PasswordStrengthComponent } from "@bitwarden/angular/tools/password-strength/password-strength.component"; +import { UserVerificationDialogComponent } from "@bitwarden/auth/angular"; import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; -import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; import { EventType } from "@bitwarden/common/enums"; import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -67,7 +67,6 @@ export class ExportComponent implements OnInit, OnDestroy { protected eventCollectionService: EventCollectionService, private policyService: PolicyService, private logService: LogService, - private userVerificationService: UserVerificationService, private formBuilder: UntypedFormBuilder, protected fileDownloadService: FileDownloadService, protected dialogService: DialogService, @@ -154,7 +153,21 @@ export class ExportComponent implements OnInit, OnDestroy { } } - async submit() { + submit = async () => { + if (this.isFileEncryptedExport && this.filePassword != this.confirmFilePassword) { + this.platformUtilsService.showToast( + "error", + this.i18nService.t("errorOccurred"), + this.i18nService.t("filePasswordAndConfirmFilePasswordDoNotMatch"), + ); + return; + } + + this.exportForm.markAllAsTouched(); + if (this.exportForm.invalid) { + return; + } + if (this.disabledByPolicy) { this.platformUtilsService.showToast( "error", @@ -164,49 +177,52 @@ export class ExportComponent implements OnInit, OnDestroy { return; } - const acceptedWarning = await this.warningDialog(); - if (!acceptedWarning) { - return; - } - const secret = this.exportForm.get("secret").value; - - try { - await this.userVerificationService.verifyUser(secret); - } catch (e) { - this.platformUtilsService.showToast("error", this.i18nService.t("errorOccurred"), e.message); + const userVerified = await this.verifyUser(); + if (!userVerified) { return; } - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.doExport(); - } - - async warningDialog() { - if (this.encryptedFormat) { - return await this.dialogService.openSimpleDialog({ - title: { key: "confirmVaultExport" }, - content: - this.i18nService.t("encExportKeyWarningDesc") + - " " + - this.i18nService.t("encExportAccountWarningDesc"), - acceptButtonText: { key: "exportVault" }, - type: "warning", - }); - } else { - return await this.dialogService.openSimpleDialog({ - title: { key: "confirmVaultExport" }, - content: { key: "exportWarningDesc" }, - acceptButtonText: { key: "exportVault" }, - type: "warning", - }); - } - } + await this.doExport(); + }; protected saved() { this.onSaved.emit(); } + private async verifyUser(): Promise { + let confirmDescription = "exportWarningDesc"; + if (this.isFileEncryptedExport) { + confirmDescription = "fileEncryptedExportWarningDesc"; + } else if (this.isAccountEncryptedExport) { + confirmDescription = "encExportKeyWarningDesc"; + } + + const result = await UserVerificationDialogComponent.open(this.dialogService, { + title: "confirmVaultExport", + bodyText: confirmDescription, + confirmButtonOptions: { + text: "exportVault", + type: "primary", + }, + }); + + // Handle the result of the dialog based on user action and verification success + if (result.userAction === "cancel") { + // User cancelled the dialog + return false; + } + + // User confirmed the dialog so check verification success + if (!result.verificationSuccess) { + if (result.noAvailableClientVerificationMethods) { + // No client-side verification methods are available + // Could send user to configure a verification method like PIN or biometrics + } + return false; + } + return true; + } + protected async getExportData(): Promise { return Utils.isNullOrWhitespace(this.organizationId) ? this.exportService.getExport(this.format, this.filePassword)