diff --git a/libs/auth/src/angular/login/desktop-login.service.ts b/libs/auth/src/angular/login/desktop-login.service.ts new file mode 100644 index 0000000000..776c9c3d6f --- /dev/null +++ b/libs/auth/src/angular/login/desktop-login.service.ts @@ -0,0 +1,57 @@ +import { Injectable, NgZone } from "@angular/core"; + +import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service"; +import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; + +/** + * Functionality for the desktop login component. + */ +@Injectable({ + providedIn: "root", +}) +export class DesktopLoginService { + private deferFocus: boolean = null; + + constructor( + private broadcasterService: BroadcasterService, + private messagingService: MessagingService, + private ngZone: NgZone, + ) {} + + /** + * Sets up the window focus handler. + * + * @param focusInputCallback + */ + setupWindowFocusHandler(focusInputCallback: () => void): void { + const subscriptionId = "LoginComponent"; + // TODO-rr-bw: refactor to not use deprecated broadcaster service. + this.broadcasterService.subscribe(subscriptionId, (message: any) => { + this.ngZone.run(() => { + if (message.command === "windowIsFocused") { + this.handleWindowFocus(message.windowIsFocused, focusInputCallback); + } + }); + }); + + this.messagingService.send("getWindowIsFocused"); + } + + /** + * Handles the window focus event. + * + * @param windowIsFocused + * @param focusInputCallback + */ + private handleWindowFocus(windowIsFocused: boolean, focusInputCallback: () => void): void { + if (this.deferFocus === null) { + this.deferFocus = !windowIsFocused; + if (!this.deferFocus) { + focusInputCallback(); + } + } else if (this.deferFocus && windowIsFocused) { + focusInputCallback(); + this.deferFocus = false; + } + } +} diff --git a/libs/auth/src/angular/login/extension-login.service.spec.ts b/libs/auth/src/angular/login/extension-login.service.spec.ts new file mode 100644 index 0000000000..1069eeecb5 --- /dev/null +++ b/libs/auth/src/angular/login/extension-login.service.spec.ts @@ -0,0 +1,45 @@ +import { TestBed } from "@angular/core/testing"; +import { Router } from "@angular/router"; + +import { LoginEmailServiceAbstraction } from "@bitwarden/auth/common"; + +import { ExtensionLoginService } from "./extension-login.service"; + +describe("ExtensionLoginService", () => { + let service: ExtensionLoginService; + let routerMock: jest.Mocked; + let loginEmailServiceMock: jest.Mocked; + + beforeEach(() => { + routerMock = { + navigate: jest.fn(), + } as unknown as jest.Mocked; + + loginEmailServiceMock = { + clearValues: jest.fn(), + } as unknown as jest.Mocked; + + TestBed.configureTestingModule({ + providers: [ + ExtensionLoginService, + { provide: Router, useValue: routerMock }, + { provide: LoginEmailServiceAbstraction, useValue: loginEmailServiceMock }, + ], + }); + + service = TestBed.inject(ExtensionLoginService); + }); + + it("creates the service", () => { + expect(service).toBeTruthy(); + }); + + describe("handleSuccessfulLogin", () => { + it("clears login email service values and navigates to vault", async () => { + await service.handleSuccessfulLogin(); + + expect(loginEmailServiceMock.clearValues).toHaveBeenCalled(); + expect(routerMock.navigate).toHaveBeenCalledWith(["/tabs/vault"]); + }); + }); +}); diff --git a/libs/auth/src/angular/login/extension-login.service.ts b/libs/auth/src/angular/login/extension-login.service.ts new file mode 100644 index 0000000000..e168b79823 --- /dev/null +++ b/libs/auth/src/angular/login/extension-login.service.ts @@ -0,0 +1,25 @@ +import { Injectable } from "@angular/core"; +import { Router } from "@angular/router"; + +import { LoginEmailServiceAbstraction } from "@bitwarden/auth/common"; + +/** + * Functionality for the extension login component. + */ +@Injectable({ + providedIn: "root", +}) +export class ExtensionLoginService { + constructor( + private router: Router, + private loginEmailService: LoginEmailServiceAbstraction, + ) {} + + /** + * Handles the successful login - clears the login email service values and navigates to the vault. + */ + async handleSuccessfulLogin(): Promise { + this.loginEmailService.clearValues(); + await this.router.navigate(["/tabs/vault"]); + } +} diff --git a/libs/auth/src/angular/login/login.component.ts b/libs/auth/src/angular/login/login.component.ts index 88183bd0f4..595451aa85 100644 --- a/libs/auth/src/angular/login/login.component.ts +++ b/libs/auth/src/angular/login/login.component.ts @@ -42,7 +42,10 @@ import { import { AnonLayoutWrapperDataService } from "../anon-layout/anon-layout-wrapper-data.service"; import { WaveIcon } from "../icons"; +import { DesktopLoginService } from "./desktop-login.service"; +import { ExtensionLoginService } from "./extension-login.service"; import { LoginComponentService } from "./login-component.service"; +import { WebLoginService } from "./web-login.service"; const BroadcasterSubscriptionId = "LoginComponent"; @@ -136,6 +139,9 @@ export class LoginComponent implements OnInit, OnDestroy { private router: Router, private syncService: SyncService, private toastService: ToastService, + private webLoginService: WebLoginService, + private desktopLoginService: DesktopLoginService, + private extensionLoginService: ExtensionLoginService, ) { this.clientType = this.platformUtilsService.getClientType(); this.showPasswordless = this.loginComponentService.getShowPasswordlessFlag(); @@ -266,15 +272,14 @@ export class LoginComponent implements OnInit, OnDestroy { // If none of the above cases are true, proceed with login... // ...on Web if (this.clientType === ClientType.Web) { - // ...on Browser/Desktop await this.goAfterLogIn(authResult.userId); + // ...on Browser/Desktop } else if (this.clientType === ClientType.Browser) { + await this.extensionLoginService.handleSuccessfulLogin(); + } else { + await this.router.navigate(["vault"]); this.loginEmailService.clearValues(); - await this.router.navigate(["/tabs/vault"]); } - - await this.router.navigate(["vault"]); - this.loginEmailService.clearValues(); } protected async launchSsoBrowserWindow(clientId: "browser" | "desktop"): Promise { @@ -518,24 +523,21 @@ export class LoginComponent implements OnInit, OnDestroy { } private async webOnInit(): Promise { - this.activatedRoute.queryParams.pipe(first(), takeUntil(this.destroy$)).subscribe((qParams) => { - if (qParams.org != null) { - const route = this.router.createUrlTree(["create-organization"], { - queryParams: { plan: qParams.org }, - }); - this.loginComponentService.setPreviousUrl(route); - } - - /* If there is a parameter called 'sponsorshipToken', they are coming - from an email for sponsoring a families organization. Therefore set - the prevousUrl to /setup/families-for-enterprise?token= */ - if (qParams.sponsorshipToken != null) { - const route = this.router.createUrlTree(["setup/families-for-enterprise"], { - queryParams: { token: qParams.sponsorshipToken }, - }); - this.loginComponentService.setPreviousUrl(route); - } - }); + this.activatedRoute.queryParams + .pipe( + first(), + switchMap((qParams) => this.webLoginService.handleQueryParams(qParams)), + takeUntil(this.destroy$), + ) + .subscribe({ + error: (error: unknown) => { + this.toastService.showToast({ + variant: "error", + title: this.i18nService.t("errorOccurred"), + message: String(error), + }); + }, + }); /** * TODO-rr-bw: Verify the following @@ -554,27 +556,6 @@ export class LoginComponent implements OnInit, OnDestroy { private async desktopOnInit(): Promise { await this.getLoginWithDevice(this.loggedEmail); - - // TODO-rr-bw: 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"); + this.desktopLoginService.setupWindowFocusHandler(() => this.focusInput()); } } diff --git a/libs/auth/src/angular/login/web-login.service.spec.ts b/libs/auth/src/angular/login/web-login.service.spec.ts new file mode 100644 index 0000000000..703aa93de4 --- /dev/null +++ b/libs/auth/src/angular/login/web-login.service.spec.ts @@ -0,0 +1,72 @@ +import { TestBed } from "@angular/core/testing"; +import { Router } from "@angular/router"; + +import { LoginComponentService } from "./login-component.service"; +import { WebLoginService } from "./web-login.service"; + +describe("WebLoginService", () => { + let service: WebLoginService; + let routerMock: jest.Mocked; + let loginComponentServiceMock: jest.Mocked; + + beforeEach(() => { + routerMock = { + createUrlTree: jest.fn(), + } as unknown as jest.Mocked; + + loginComponentServiceMock = { + setPreviousUrl: jest.fn(), + } as unknown as jest.Mocked; + + TestBed.configureTestingModule({ + providers: [ + WebLoginService, + { provide: Router, useValue: routerMock }, + { provide: LoginComponentService, useValue: loginComponentServiceMock }, + ], + }); + + service = TestBed.inject(WebLoginService); + }); + + it("creates the service", () => { + expect(service).toBeTruthy(); + }); + + describe("handleQueryParams", () => { + it("sets previous URL for organization creation when org param is present", async () => { + const qParams = { org: "some-org" }; + const mockUrlTree = {} as any; + routerMock.createUrlTree.mockReturnValue(mockUrlTree); + + await service.handleQueryParams(qParams); + + expect(routerMock.createUrlTree).toHaveBeenCalledWith(["create-organization"], { + queryParams: { plan: "some-org" }, + }); + expect(loginComponentServiceMock.setPreviousUrl).toHaveBeenCalledWith(mockUrlTree); + }); + + it("sets previous URL for families sponsorship when sponsorshipToken param is present", async () => { + const qParams = { sponsorshipToken: "test-token" }; + const mockUrlTree = {} as any; + routerMock.createUrlTree.mockReturnValue(mockUrlTree); + + await service.handleQueryParams(qParams); + + expect(routerMock.createUrlTree).toHaveBeenCalledWith(["setup/families-for-enterprise"], { + queryParams: { token: "test-token" }, + }); + expect(loginComponentServiceMock.setPreviousUrl).toHaveBeenCalledWith(mockUrlTree); + }); + + it("does not set previous URL when no relevant params are present", async () => { + const qParams = { someOtherParam: "value" }; + + await service.handleQueryParams(qParams); + + expect(routerMock.createUrlTree).not.toHaveBeenCalled(); + expect(loginComponentServiceMock.setPreviousUrl).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/libs/auth/src/angular/login/web-login.service.ts b/libs/auth/src/angular/login/web-login.service.ts new file mode 100644 index 0000000000..21bada2c07 --- /dev/null +++ b/libs/auth/src/angular/login/web-login.service.ts @@ -0,0 +1,38 @@ +import { Injectable } from "@angular/core"; +import { Router } from "@angular/router"; + +import { LoginComponentService } from "./login-component.service"; + +/** + * Functionality for the web login component. + */ +@Injectable({ + providedIn: "root", +}) +export class WebLoginService { + constructor( + private router: Router, + private loginComponentService: LoginComponentService, + ) {} + + async handleQueryParams(qParams: any): Promise { + if (qParams.org != null) { + const route = this.router.createUrlTree(["create-organization"], { + queryParams: { plan: qParams.org }, + }); + this.loginComponentService.setPreviousUrl(route); + } + + /** + * If there is a parameter called 'sponsorshipToken', they are coming + * from an email for sponsoring a families organization. Therefore set + * the previousUrl to /setup/families-for-enterprise?token= + */ + if (qParams.sponsorshipToken != null) { + const route = this.router.createUrlTree(["setup/families-for-enterprise"], { + queryParams: { token: qParams.sponsorshipToken }, + }); + this.loginComponentService.setPreviousUrl(route); + } + } +}