diff --git a/apps/desktop/src/auth/login/desktop-login-component.service.spec.ts b/apps/desktop/src/auth/login/desktop-login-component.service.spec.ts index 94bc73d2e9..c88627250c 100644 --- a/apps/desktop/src/auth/login/desktop-login-component.service.spec.ts +++ b/apps/desktop/src/auth/login/desktop-login-component.service.spec.ts @@ -107,22 +107,17 @@ describe("DesktopLoginComponentService", () => { }); describe("redirectToSso", () => { - // Array of all permutations of isAppImage, isSnapStore, and isDev + // Array of all permutations of isAppImage and isDev const permutations = [ - [true, false, false], // Case 1: isAppImage true - [false, true, false], // Case 2: isSnapStore true - [false, false, true], // Case 3: isDev true - [true, true, false], // Case 4: isAppImage and isSnapStore true - [true, false, true], // Case 5: isAppImage and isDev true - [false, true, true], // Case 6: isSnapStore and isDev true - [true, true, true], // Case 7: all true - [false, false, false], // Case 8: all false + [true, false], // Case 1: isAppImage true + [false, true], // Case 2: isDev true + [true, true], // Case 3: all true + [false, false], // Case 4: all false ]; - permutations.forEach(([isAppImage, isSnapStore, isDev]) => { - it(`executes correct logic for isAppImage=${isAppImage}, isSnapStore=${isSnapStore}, isDev=${isDev}`, async () => { + permutations.forEach(([isAppImage, isDev]) => { + it(`executes correct logic for isAppImage=${isAppImage}, isDev=${isDev}`, async () => { (global as any).ipc.platform.isAppImage = isAppImage; - (global as any).ipc.platform.isSnapStore = isSnapStore; (global as any).ipc.platform.isDev = isDev; const email = "test@bitwarden.com"; @@ -136,7 +131,7 @@ describe("DesktopLoginComponentService", () => { await service.redirectToSsoLogin(email); - if (isAppImage || isSnapStore || isDev) { + if (isAppImage || isDev) { expect(ipc.platform.localhostCallbackService.openSsoPrompt).toHaveBeenCalledWith( codeChallenge, state, diff --git a/apps/desktop/src/auth/login/desktop-login-component.service.ts b/apps/desktop/src/auth/login/desktop-login-component.service.ts index 7341e0fe03..60e7791b38 100644 --- a/apps/desktop/src/auth/login/desktop-login-component.service.ts +++ b/apps/desktop/src/auth/login/desktop-login-component.service.ts @@ -51,7 +51,7 @@ export class DesktopLoginComponentService ): Promise { // For platforms that cannot support a protocol-based (e.g. bitwarden://) callback, we use a localhost callback // Otherwise, we launch the SSO component in a browser window and wait for the callback - if (ipc.platform.isAppImage || ipc.platform.isSnapStore || ipc.platform.isDev) { + if (ipc.platform.isAppImage || ipc.platform.isDev) { await this.initiateSsoThroughLocalhostCallback(email, state, codeChallenge); } else { const env = await firstValueFrom(this.environmentService.environment$); diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index 2caad95811..3cce9b5357 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -1470,7 +1470,7 @@ const safeProviders: SafeProvider[] = [ safeProvider({ provide: LoginSuccessHandlerService, useClass: DefaultLoginSuccessHandlerService, - deps: [SyncService, UserAsymmetricKeysRegenerationService], + deps: [SyncService, UserAsymmetricKeysRegenerationService, LoginEmailService], }), safeProvider({ provide: TaskService, diff --git a/libs/auth/src/angular/login-via-auth-request/login-via-auth-request.component.ts b/libs/auth/src/angular/login-via-auth-request/login-via-auth-request.component.ts index cb4b7bc4c8..0af52e02b8 100644 --- a/libs/auth/src/angular/login-via-auth-request/login-via-auth-request.component.ts +++ b/libs/auth/src/angular/login-via-auth-request/login-via-auth-request.component.ts @@ -809,11 +809,6 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy { } private async handleSuccessfulLoginNavigation(userId: UserId) { - if (this.flow === Flow.StandardAuthRequest) { - // Only need to set remembered email on standard login with auth req flow - await this.loginEmailService.saveEmailSettings(); - } - await this.loginSuccessHandlerService.run(userId); await this.router.navigate(["vault"]); } diff --git a/libs/auth/src/angular/login/login.component.html b/libs/auth/src/angular/login/login.component.html index b04a54da42..35ef1fa9b5 100644 --- a/libs/auth/src/angular/login/login.component.html +++ b/libs/auth/src/angular/login/login.component.html @@ -27,7 +27,12 @@ - + {{ "rememberEmail" | i18n }} @@ -39,18 +44,18 @@
{{ "or" | i18n }}
- + - {{ "logInWithPasskey" | i18n }} - + diff --git a/libs/auth/src/angular/login/login.component.ts b/libs/auth/src/angular/login/login.component.ts index cc38ec5dfb..55c282be55 100644 --- a/libs/auth/src/angular/login/login.component.ts +++ b/libs/auth/src/angular/login/login.component.ts @@ -148,6 +148,62 @@ export class LoginComponent implements OnInit, OnDestroy { this.destroy$.complete(); } + private async defaultOnInit(): Promise { + let paramEmailIsSet = false; + + const params = await firstValueFrom(this.activatedRoute.queryParams); + + if (params) { + const qParamsEmail = params.email; + + // If there is an email in the query params, set that email as the form field value + if (qParamsEmail != null && qParamsEmail.indexOf("@") > -1) { + this.formGroup.controls.email.setValue(qParamsEmail); + paramEmailIsSet = true; + } + } + + // If there are no params or no email in the query params, loadEmailSettings from state + if (!paramEmailIsSet) { + await this.loadRememberedEmail(); + } + + // Check to see if the device is known so that we can show the Login with Device option + if (this.emailFormControl.value) { + await this.getKnownDevice(this.emailFormControl.value); + } + + // Backup check to handle unknown case where activatedRoute is not available + // This shouldn't happen under normal circumstances + if (!this.activatedRoute) { + await this.loadRememberedEmail(); + } + } + + private async desktopOnInit(): Promise { + // TODO: refactor to not use deprecated broadcaster service. + this.broadcasterService.subscribe(BroadcasterSubscriptionId, async (message: any) => { + this.ngZone.run(() => { + switch (message.command) { + case "windowIsFocused": + if (this.deferFocus === null) { + this.deferFocus = !message.windowIsFocused; + if (!this.deferFocus) { + this.focusInput(); + } + } else if (this.deferFocus && message.windowIsFocused) { + this.focusInput(); + this.deferFocus = false; + } + break; + default: + } + }); + }); + + this.messagingService.send("getWindowIsFocused"); + } + submit = async (): Promise => { if (this.clientType === ClientType.Desktop) { if (this.loginUiState !== LoginUiState.MASTER_PASSWORD_ENTRY) { @@ -172,7 +228,6 @@ export class LoginComponent implements OnInit, OnDestroy { try { const authResult = await this.loginStrategyService.logIn(credentials); - await this.saveEmailSettings(); await this.handleAuthResult(authResult); } catch (error) { this.logService.error(error); @@ -250,7 +305,6 @@ export class LoginComponent implements OnInit, OnDestroy { // User logged in successfully so execute side effects await this.loginSuccessHandlerService.run(authResult.userId); - this.loginEmailService.clearValues(); // Determine where to send the user next if (authResult.forcePasswordReset != ForceSetPasswordReason.None) { @@ -288,7 +342,6 @@ export class LoginComponent implements OnInit, OnDestroy { await this.router.navigate(["vault"]); } } - /** * Checks if the master password meets the enforced policy requirements * and if the user is required to change their password. @@ -344,11 +397,10 @@ export class LoginComponent implements OnInit, OnDestroy { return; } - await this.saveEmailSettings(); await this.router.navigate(["/login-with-device"]); } - protected async validateEmail(): Promise { + protected async emailIsValid(): Promise { this.formGroup.controls.email.markAsTouched(); this.formGroup.controls.email.updateValueAndValidity({ onlySelf: true, emitEvent: true }); return this.formGroup.controls.email.valid; @@ -399,37 +451,14 @@ export class LoginComponent implements OnInit, OnDestroy { } } - /** - * Set the email value from the input field. - * @param event The event object from the input field. - */ - onEmailInput(event: Event) { - const emailInput = event.target as HTMLInputElement; - this.formGroup.controls.email.setValue(emailInput.value); - this.loginEmailService.setLoginEmail(emailInput.value); - } - isLoginWithPasskeySupported() { return this.loginComponentService.isLoginWithPasskeySupported(); } protected async goToHint(): Promise { - await this.saveEmailSettings(); await this.router.navigateByUrl("/hint"); } - protected async saveEmailSettings(): Promise { - const email = this.formGroup.value.email; - if (!email) { - this.logService.error("Email is required to save email settings."); - return; - } - - await this.loginEmailService.setLoginEmail(email); - this.loginEmailService.setRememberEmail(this.formGroup.value.rememberEmail ?? false); - await this.loginEmailService.saveEmailSettings(); - } - /** * Continue button clicked (or enter key pressed). * Adds the login url to the browser's history so that the back button can be used to go back to the email entry state. @@ -445,13 +474,44 @@ export class LoginComponent implements OnInit, OnDestroy { * Continue to the master password entry state (only if email is validated) */ protected async continue(): Promise { - const isEmailValid = await this.validateEmail(); + const isEmailValid = await this.emailIsValid(); if (isEmailValid) { await this.toggleLoginUiState(LoginUiState.MASTER_PASSWORD_ENTRY); } } + /** + * Handle the Login with Passkey button click. + * We need a handler here in order to persist the remember email selection to state before routing. + * @param event - The event object. + */ + async handleLoginWithPasskeyClick() { + await this.router.navigate(["/login-with-passkey"]); + } + + /** + * Handle the SSO button click. + * @param event - The event object. + */ + async handleSsoClick() { + // Make sure the email is valid + const isEmailValid = await this.emailIsValid(); + if (!isEmailValid) { + return; + } + + // Make sure the email is not empty, for type safety + const email = this.formGroup.value.email; + if (!email) { + this.logService.error("Email is required for SSO"); + return; + } + + // Send the user to SSO, either through routing or through redirecting to the web app + await this.loginComponentService.redirectToSsoLogin(email); + } + /** * Call to check if the device is known. * Known means that the user has logged in with this device before. @@ -473,23 +533,17 @@ export class LoginComponent implements OnInit, OnDestroy { } } - private async loadEmailSettings(): Promise { - // Try to load the email from memory first - const email = await firstValueFrom(this.loginEmailService.loginEmail$); - const rememberEmail = this.loginEmailService.getRememberEmail(); - - if (email) { - this.formGroup.controls.email.setValue(email); - this.formGroup.controls.rememberEmail.setValue(rememberEmail); + /** + * Check to see if the user has remembered an email on the current device. + * If so, set the email in the form field and set rememberEmail to true. If not, set rememberEmail to false. + */ + private async loadRememberedEmail(): Promise { + const storedEmail = await firstValueFrom(this.loginEmailService.rememberedEmail$); + if (storedEmail) { + this.formGroup.controls.email.setValue(storedEmail); + this.formGroup.controls.rememberEmail.setValue(true); } else { - // If there is no email in memory, check for a storedEmail on disk - const storedEmail = await firstValueFrom(this.loginEmailService.storedEmail$); - - if (storedEmail) { - this.formGroup.controls.email.setValue(storedEmail); - // If there is a storedEmail, rememberEmail defaults to true - this.formGroup.controls.rememberEmail.setValue(true); - } + this.formGroup.controls.rememberEmail.setValue(false); } } @@ -503,62 +557,6 @@ export class LoginComponent implements OnInit, OnDestroy { ?.focus(); } - private async defaultOnInit(): Promise { - let paramEmailIsSet = false; - - const params = await firstValueFrom(this.activatedRoute.queryParams); - - if (params) { - const qParamsEmail = params.email; - - // If there is an email in the query params, set that email as the form field value - if (qParamsEmail != null && qParamsEmail.indexOf("@") > -1) { - this.formGroup.controls.email.setValue(qParamsEmail); - paramEmailIsSet = true; - } - } - - // If there are no params or no email in the query params, loadEmailSettings from state - if (!paramEmailIsSet) { - await this.loadEmailSettings(); - } - - // Check to see if the device is known so that we can show the Login with Device option - if (this.emailFormControl.value) { - await this.getKnownDevice(this.emailFormControl.value); - } - - // Backup check to handle unknown case where activatedRoute is not available - // This shouldn't happen under normal circumstances - if (!this.activatedRoute) { - await this.loadEmailSettings(); - } - } - - private async desktopOnInit(): Promise { - // TODO: refactor to not use deprecated broadcaster service. - this.broadcasterService.subscribe(BroadcasterSubscriptionId, async (message: any) => { - this.ngZone.run(() => { - switch (message.command) { - case "windowIsFocused": - if (this.deferFocus === null) { - this.deferFocus = !message.windowIsFocused; - if (!this.deferFocus) { - this.focusInput(); - } - } else if (this.deferFocus && message.windowIsFocused) { - this.focusInput(); - this.deferFocus = false; - } - break; - default: - } - }); - }); - - this.messagingService.send("getWindowIsFocused"); - } - /** * Helper function to determine if the back button should be shown. * @returns true if the back button should be shown. @@ -597,27 +595,46 @@ export class LoginComponent implements OnInit, OnDestroy { }; /** - * Handle the SSO button click. + * Persist the entered email address and the user's choice to remember it to state. */ - async handleSsoClick() { - const email = this.formGroup.value.email; - - // Make sure the email is valid - const isEmailValid = await this.validateEmail(); - if (!isEmailValid) { - return; + private async persistEmailIfValid(): Promise { + if (await this.emailIsValid()) { + const email = this.formGroup.value.email; + const rememberEmail = this.formGroup.value.rememberEmail ?? false; + if (!email) { + return; + } + await this.loginEmailService.setLoginEmail(email); + await this.loginEmailService.setRememberedEmailChoice(email, rememberEmail); + } else { + await this.loginEmailService.clearLoginEmail(); + await this.loginEmailService.clearRememberedEmail(); } + } - // Make sure the email is not empty, for type safety - if (!email) { - this.logService.error("Email is required for SSO"); - return; - } + /** + * Set the email value from the input field. + * We only update the form controls onSubmit instead of onBlur because we don't want to show validation errors until + * the user submits. This is because currently our validation errors are shown below the input fields, and + * displaying them causes the screen to "jump". + * @param event The event object from the input field. + */ + async onEmailInput(event: Event) { + const emailInput = event.target as HTMLInputElement; + this.formGroup.controls.email.setValue(emailInput.value); + await this.persistEmailIfValid(); + } - // Save the email configuration for the login component - await this.saveEmailSettings(); - - // Send the user to SSO, either through routing or through redirecting to the web app - await this.loginComponentService.redirectToSsoLogin(email); + /** + * Set the Remember Email value from the input field. + * We only update the form controls onSubmit instead of onBlur because we don't want to show validation errors until + * the user submits. This is because currently our validation errors are shown below the input fields, and + * displaying them causes the screen to "jump". + * @param event The event object from the input field. + */ + async onRememberEmailInput(event: Event) { + const rememberEmailInput = event.target as HTMLInputElement; + this.formGroup.controls.rememberEmail.setValue(rememberEmailInput.checked); + await this.persistEmailIfValid(); } } diff --git a/libs/auth/src/angular/new-device-verification/new-device-verification.component.ts b/libs/auth/src/angular/new-device-verification/new-device-verification.component.ts index 57583eb24d..c083643c9b 100644 --- a/libs/auth/src/angular/new-device-verification/new-device-verification.component.ts +++ b/libs/auth/src/angular/new-device-verification/new-device-verification.component.ts @@ -5,10 +5,10 @@ import { Router } from "@angular/router"; import { Subject, takeUntil } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { LoginSuccessHandlerService } from "@bitwarden/auth/common"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; -import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; import { AsyncActionsModule, ButtonModule, @@ -17,7 +17,6 @@ import { LinkModule, } from "@bitwarden/components"; -import { LoginEmailServiceAbstraction } from "../../common/abstractions/login-email.service"; import { LoginStrategyServiceAbstraction } from "../../common/abstractions/login-strategy.service"; /** @@ -60,8 +59,7 @@ export class NewDeviceVerificationComponent implements OnInit, OnDestroy { private loginStrategyService: LoginStrategyServiceAbstraction, private logService: LogService, private i18nService: I18nService, - private syncService: SyncService, - private loginEmailService: LoginEmailServiceAbstraction, + private loginSuccessHandlerService: LoginSuccessHandlerService, ) {} async ngOnInit() { @@ -143,9 +141,7 @@ export class NewDeviceVerificationComponent implements OnInit, OnDestroy { return; } - this.loginEmailService.clearValues(); - - await this.syncService.fullSync(true); + this.loginSuccessHandlerService.run(authResult.userId); // If verification succeeds, navigate to vault await this.router.navigate(["/vault"]); diff --git a/libs/auth/src/angular/two-factor-auth/two-factor-auth.component.spec.ts b/libs/auth/src/angular/two-factor-auth/two-factor-auth.component.spec.ts index 20e9aa7304..8378ac7794 100644 --- a/libs/auth/src/angular/two-factor-auth/two-factor-auth.component.spec.ts +++ b/libs/auth/src/angular/two-factor-auth/two-factor-auth.component.spec.ts @@ -254,19 +254,6 @@ describe("TwoFactorAuthComponent", () => { ); }); - it("calls loginEmailService.clearValues() when login is successful", async () => { - // Arrange - mockLoginStrategyService.logInTwoFactor.mockResolvedValue(new AuthResult()); - // spy on loginEmailService.clearValues - const clearValuesSpy = jest.spyOn(mockLoginEmailService, "clearValues"); - - // Act - await component.submit(token, remember); - - // Assert - expect(clearValuesSpy).toHaveBeenCalled(); - }); - describe("Set Master Password scenarios", () => { beforeEach(() => { const authResult = new AuthResult(); diff --git a/libs/auth/src/angular/two-factor-auth/two-factor-auth.component.ts b/libs/auth/src/angular/two-factor-auth/two-factor-auth.component.ts index 74b5db634f..6cdf42b76d 100644 --- a/libs/auth/src/angular/two-factor-auth/two-factor-auth.component.ts +++ b/libs/auth/src/angular/two-factor-auth/two-factor-auth.component.ts @@ -382,7 +382,6 @@ export class TwoFactorAuthComponent implements OnInit, OnDestroy { // User is fully logged in so handle any post login logic before executing navigation await this.loginSuccessHandlerService.run(authResult.userId); - this.loginEmailService.clearValues(); // Save off the OrgSsoIdentifier for use in the TDE flows // - TDE login decryption options component diff --git a/libs/auth/src/common/abstractions/login-email.service.ts b/libs/auth/src/common/abstractions/login-email.service.ts index fc72c4cd26..4d57517036 100644 --- a/libs/auth/src/common/abstractions/login-email.service.ts +++ b/libs/auth/src/common/abstractions/login-email.service.ts @@ -1,43 +1,34 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { Observable } from "rxjs"; export abstract class LoginEmailServiceAbstraction { /** - * An observable that monitors the loginEmail in memory. + * An observable that monitors the loginEmail. * The loginEmail is the email that is being used in the current login process. */ - loginEmail$: Observable; + abstract loginEmail$: Observable; /** - * An observable that monitors the storedEmail on disk. + * An observable that monitors the remembered email. * This will return null if an account is being added. */ - storedEmail$: Observable; + abstract rememberedEmail$: Observable; /** * Sets the loginEmail in memory. * The loginEmail is the email that is being used in the current login process. + * Consumed through `loginEmail$` observable. */ - setLoginEmail: (email: string) => Promise; + abstract setLoginEmail: (email: string) => Promise; /** - * Gets from memory whether or not the email should be stored on disk when `saveEmailSettings` is called. - * @returns A boolean stating whether or not the email should be stored on disk. + * Persist the user's choice of whether to remember their email on subsequent login attempts. + * Consumed through `rememberedEmail$` observable. */ - getRememberEmail: () => boolean; + abstract setRememberedEmailChoice: (email: string, remember: boolean) => Promise; /** - * Sets in memory whether or not the email should be stored on disk when `saveEmailSettings` is called. + * Clears the in-progress login email, to be used after a successful login. */ - setRememberEmail: (value: boolean) => void; + abstract clearLoginEmail: () => Promise; + /** - * Sets the email and rememberEmail properties in memory to null. + * Clears the remembered email. */ - clearValues: () => void; - /** - * Saves or clears the email on disk - * - If an account is being added, only changes the stored email when rememberEmail is true. - * - If rememberEmail is true, sets the email on disk to the current email. - * - If rememberEmail is false, sets the email on disk to null. - * Always clears the email and rememberEmail properties from memory. - * @returns A promise that resolves once the email settings are saved. - */ - saveEmailSettings: () => Promise; + abstract clearRememberedEmail: () => Promise; } diff --git a/libs/auth/src/common/services/login-email/login-email.service.spec.ts b/libs/auth/src/common/services/login-email/login-email.service.spec.ts index 8bb9b962ea..819c9d496b 100644 --- a/libs/auth/src/common/services/login-email/login-email.service.spec.ts +++ b/libs/auth/src/common/services/login-email/login-email.service.spec.ts @@ -14,7 +14,7 @@ import { UserId } from "@bitwarden/common/types/guid"; import { LoginEmailService, STORED_EMAIL } from "./login-email.service"; describe("LoginEmailService", () => { - let sut: LoginEmailService; + let service: LoginEmailService; let accountService: FakeAccountService; let authService: MockProxy; @@ -34,119 +34,93 @@ describe("LoginEmailService", () => { mockAuthStatuses$ = new BehaviorSubject>({}); authService.authStatuses$ = mockAuthStatuses$; - sut = new LoginEmailService(accountService, authService, stateProvider); + service = new LoginEmailService(accountService, authService, stateProvider); }); afterEach(() => { jest.clearAllMocks(); }); - describe("storedEmail$", () => { - it("returns the stored email when not adding an account", async () => { - await sut.setLoginEmail("userEmail@bitwarden.com"); - sut.setRememberEmail(true); - await sut.saveEmailSettings(); + describe("rememberedEmail$", () => { + it("returns the remembered email when not adding an account", async () => { + const testEmail = "test@bitwarden.com"; - const result = await firstValueFrom(sut.storedEmail$); + await service.setRememberedEmailChoice(testEmail, true); - expect(result).toEqual("userEmail@bitwarden.com"); + const result = await firstValueFrom(service.rememberedEmail$); + + expect(result).toEqual(testEmail); }); - it("returns the stored email when not adding an account and the user has just logged in", async () => { - await sut.setLoginEmail("userEmail@bitwarden.com"); - sut.setRememberEmail(true); - await sut.saveEmailSettings(); + it("returns the remembered email when not adding an account and the user has just logged in", async () => { + const testEmail = "test@bitwarden.com"; + + await service.setRememberedEmailChoice(testEmail, true); mockAuthStatuses$.next({ [userId]: AuthenticationStatus.Unlocked }); // account service already initialized with userId as active user - const result = await firstValueFrom(sut.storedEmail$); + const result = await firstValueFrom(service.rememberedEmail$); - expect(result).toEqual("userEmail@bitwarden.com"); + expect(result).toEqual(testEmail); }); it("returns null when adding an account", async () => { - await sut.setLoginEmail("userEmail@bitwarden.com"); - sut.setRememberEmail(true); - await sut.saveEmailSettings(); + const testEmail = "test@bitwarden.com"; + + await service.setRememberedEmailChoice(testEmail, true); mockAuthStatuses$.next({ [userId]: AuthenticationStatus.Unlocked, ["OtherUserId" as UserId]: AuthenticationStatus.Locked, }); - const result = await firstValueFrom(sut.storedEmail$); + const result = await firstValueFrom(service.rememberedEmail$); expect(result).toBeNull(); }); }); - describe("saveEmailSettings", () => { - it("saves the email when not adding an account", async () => { - await sut.setLoginEmail("userEmail@bitwarden.com"); - sut.setRememberEmail(true); - await sut.saveEmailSettings(); + describe("setRememberedEmailChoice", () => { + it("sets the remembered email when remember is true", async () => { + const testEmail = "test@bitwarden.com"; + + await service.setRememberedEmailChoice(testEmail, true); const result = await firstValueFrom(storedEmailState.state$); - expect(result).toEqual("userEmail@bitwarden.com"); + expect(result).toEqual(testEmail); }); - it("clears the email when not adding an account and rememberEmail is false", async () => { + it("clears the remembered email when remember is false", async () => { storedEmailState.stateSubject.next("initialEmail@bitwarden.com"); - await sut.setLoginEmail("userEmail@bitwarden.com"); - sut.setRememberEmail(false); - await sut.saveEmailSettings(); + const testEmail = "test@bitwarden.com"; + + await service.setRememberedEmailChoice(testEmail, false); const result = await firstValueFrom(storedEmailState.state$); expect(result).toBeNull(); }); + }); - it("saves the email when adding an account", async () => { - mockAuthStatuses$.next({ - [userId]: AuthenticationStatus.Unlocked, - ["OtherUserId" as UserId]: AuthenticationStatus.Locked, - }); + describe("setLoginEmail", () => { + it("sets the login email", async () => { + const testEmail = "test@bitwarden.com"; + await service.setLoginEmail(testEmail); - await sut.setLoginEmail("userEmail@bitwarden.com"); - sut.setRememberEmail(true); - await sut.saveEmailSettings(); - - const result = await firstValueFrom(storedEmailState.state$); - - expect(result).toEqual("userEmail@bitwarden.com"); + expect(await firstValueFrom(service.loginEmail$)).toEqual(testEmail); }); + }); - it("does not clear the email when adding an account and rememberEmail is false", async () => { - storedEmailState.stateSubject.next("initialEmail@bitwarden.com"); + describe("clearLoginEmail", () => { + it("clears the login email", async () => { + const testEmail = "test@bitwarden.com"; + await service.setLoginEmail(testEmail); + await service.clearLoginEmail(); - mockAuthStatuses$.next({ - [userId]: AuthenticationStatus.Unlocked, - ["OtherUserId" as UserId]: AuthenticationStatus.Locked, - }); - - await sut.setLoginEmail("userEmail@bitwarden.com"); - sut.setRememberEmail(false); - await sut.saveEmailSettings(); - - const result = await firstValueFrom(storedEmailState.state$); - - // result should not be null - expect(result).toEqual("initialEmail@bitwarden.com"); - }); - - it("does not clear the email and rememberEmail after saving", async () => { - // Browser uses these values to maintain the email between login and 2fa components so - // we do not want to clear them too early. - await sut.setLoginEmail("userEmail@bitwarden.com"); - sut.setRememberEmail(true); - await sut.saveEmailSettings(); - - const result = await firstValueFrom(sut.loginEmail$); - - expect(result).toBe("userEmail@bitwarden.com"); + expect(await firstValueFrom(service.loginEmail$)).toBeNull(); }); }); }); diff --git a/libs/auth/src/common/services/login-email/login-email.service.ts b/libs/auth/src/common/services/login-email/login-email.service.ts index aa13afd500..6ca817772b 100644 --- a/libs/auth/src/common/services/login-email/login-email.service.ts +++ b/libs/auth/src/common/services/login-email/login-email.service.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { Observable, firstValueFrom, switchMap } from "rxjs"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; @@ -26,8 +24,6 @@ export const STORED_EMAIL = new KeyDefinition(LOGIN_EMAIL_DISK, "storedE }); export class LoginEmailService implements LoginEmailServiceAbstraction { - private rememberEmail: boolean; - // True if an account is currently being added through account switching private readonly addingAccount$: Observable; @@ -35,7 +31,7 @@ export class LoginEmailService implements LoginEmailServiceAbstraction { loginEmail$: Observable; private readonly storedEmailState: GlobalState; - storedEmail$: Observable; + rememberedEmail$: Observable; constructor( private accountService: AccountService, @@ -60,7 +56,7 @@ export class LoginEmailService implements LoginEmailServiceAbstraction { this.loginEmail$ = this.loginEmailState.state$; - this.storedEmail$ = this.storedEmailState.state$.pipe( + this.rememberedEmail$ = this.storedEmailState.state$.pipe( switchMap(async (storedEmail) => { // When adding an account, we don't show the stored email if (await firstValueFrom(this.addingAccount$)) { @@ -71,44 +67,32 @@ export class LoginEmailService implements LoginEmailServiceAbstraction { ); } + /** Sets the login email in memory. + * The login email is the email that is being used in the current login process. + */ async setLoginEmail(email: string) { await this.loginEmailState.update((_) => email); } - getRememberEmail() { - return this.rememberEmail; + /** + * Clears the in-progress login email from state. + * Note: Only clear on successful login or you are sure they are not needed. + * The extension client uses these values to maintain the email between login and 2fa components so + * we do not want to clear them too early. + */ + async clearLoginEmail() { + await this.loginEmailState.update((_) => null); } - setRememberEmail(value: boolean) { - this.rememberEmail = value ?? false; + async setRememberedEmailChoice(email: string, remember: boolean): Promise { + if (remember) { + await this.storedEmailState.update((_) => email); + } else { + await this.storedEmailState.update((_) => null); + } } - // Note: only clear values on successful login or you are sure they are not needed. - // Browser uses these values to maintain the email between login and 2fa components so - // we do not want to clear them too early. - async clearValues() { - await this.setLoginEmail(null); - this.rememberEmail = false; - } - - async saveEmailSettings() { - const addingAccount = await firstValueFrom(this.addingAccount$); - const email = await firstValueFrom(this.loginEmail$); - - await this.storedEmailState.update((storedEmail) => { - // If we're adding an account, only overwrite the stored email when rememberEmail is true - if (addingAccount) { - if (this.rememberEmail) { - return email; - } - return storedEmail; - } - - // Saving with rememberEmail set to false will clear the stored email - if (this.rememberEmail) { - return email; - } - return null; - }); + async clearRememberedEmail(): Promise { + await this.storedEmailState.update((_) => null); } } diff --git a/libs/auth/src/common/services/login-success-handler/default-login-success-handler.service.ts b/libs/auth/src/common/services/login-success-handler/default-login-success-handler.service.ts index 215329051d..70d56a2ad6 100644 --- a/libs/auth/src/common/services/login-success-handler/default-login-success-handler.service.ts +++ b/libs/auth/src/common/services/login-success-handler/default-login-success-handler.service.ts @@ -3,14 +3,17 @@ import { UserId } from "@bitwarden/common/types/guid"; import { UserAsymmetricKeysRegenerationService } from "@bitwarden/key-management"; import { LoginSuccessHandlerService } from "../../abstractions/login-success-handler.service"; +import { LoginEmailService } from "../login-email/login-email.service"; export class DefaultLoginSuccessHandlerService implements LoginSuccessHandlerService { constructor( private syncService: SyncService, private userAsymmetricKeysRegenerationService: UserAsymmetricKeysRegenerationService, + private loginEmailService: LoginEmailService, ) {} async run(userId: UserId): Promise { await this.syncService.fullSync(true); await this.userAsymmetricKeysRegenerationService.regenerateIfNeeded(userId); + await this.loginEmailService.clearLoginEmail(); } }