diff --git a/apps/desktop/src/app/app.component.ts b/apps/desktop/src/app/app.component.ts index e8a9e96924ea..7840c1dd400c 100644 --- a/apps/desktop/src/app/app.component.ts +++ b/apps/desktop/src/app/app.component.ts @@ -25,7 +25,7 @@ import { import { CollectionService } from "@bitwarden/admin-console/common"; import { ModalRef } from "@bitwarden/angular/components/modal/modal.ref"; import { ModalService } from "@bitwarden/angular/services/modal.service"; -import { FingerprintDialogComponent } from "@bitwarden/auth/angular"; +import { FingerprintDialogComponent, LoginApprovalComponent } from "@bitwarden/auth/angular"; import { LogoutReason } from "@bitwarden/auth/common"; import { EventUploadService } from "@bitwarden/common/abstractions/event/event-upload.service"; import { NotificationsService } from "@bitwarden/common/abstractions/notifications.service"; @@ -67,7 +67,6 @@ import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legac import { KeyService, BiometricStateService } from "@bitwarden/key-management"; import { DeleteAccountComponent } from "../auth/delete-account.component"; -import { LoginApprovalComponent } from "../auth/login/login-approval.component"; import { MenuAccount, MenuUpdateRequest } from "../main/menu/menu.updater"; import { flagEnabled } from "../platform/flags"; import { PremiumComponent } from "../vault/app/accounts/premium.component"; diff --git a/apps/desktop/src/app/services/services.module.ts b/apps/desktop/src/app/services/services.module.ts index 2a11c78a579b..62fc93ae0b86 100644 --- a/apps/desktop/src/app/services/services.module.ts +++ b/apps/desktop/src/app/services/services.module.ts @@ -26,6 +26,7 @@ import { } from "@bitwarden/auth/angular"; import { InternalUserDecryptionOptionsServiceAbstraction, + LoginApprovalComponentServiceAbstraction, LoginEmailService, PinServiceAbstraction, } from "@bitwarden/auth/common"; @@ -87,6 +88,7 @@ import { BiometricsService, } from "@bitwarden/key-management"; +import { DesktopLoginApprovalComponentService } from "../../auth/login/desktop-login-approval-component.service"; import { DesktopLoginComponentService } from "../../auth/login/desktop-login-component.service"; import { DesktopAutofillSettingsService } from "../../autofill/services/desktop-autofill-settings.service"; import { ElectronBiometricsService } from "../../key-management/biometrics/electron-biometrics.service"; @@ -349,6 +351,11 @@ const safeProviders: SafeProvider[] = [ useClass: LoginEmailService, deps: [AccountService, AuthService, StateProvider], }), + safeProvider({ + provide: LoginApprovalComponentServiceAbstraction, + useClass: DesktopLoginApprovalComponentService, + deps: [I18nServiceAbstraction], + }), ]; @NgModule({ diff --git a/apps/desktop/src/auth/login/desktop-login-approval-component.service.spec.ts b/apps/desktop/src/auth/login/desktop-login-approval-component.service.spec.ts new file mode 100644 index 000000000000..d687ae35742e --- /dev/null +++ b/apps/desktop/src/auth/login/desktop-login-approval-component.service.spec.ts @@ -0,0 +1,89 @@ +import { TestBed } from "@angular/core/testing"; +import { mock, MockProxy } from "jest-mock-extended"; +import { Subject } from "rxjs"; + +import { LoginApprovalComponent } from "@bitwarden/auth/angular"; +import { I18nService as I18nServiceAbstraction } from "@bitwarden/common/platform/abstractions/i18n.service"; + +import { DesktopLoginApprovalComponentService } from "./desktop-login-approval-component.service"; + +describe("DesktopLoginApprovalComponentService", () => { + let service: DesktopLoginApprovalComponentService; + let i18nService: MockProxy; + let originalIpc: any; + + beforeEach(() => { + originalIpc = (global as any).ipc; + (global as any).ipc = { + auth: { + loginRequest: jest.fn(), + }, + platform: { + isWindowVisible: jest.fn(), + }, + }; + + i18nService = mock({ + t: jest.fn(), + userSetLocale$: new Subject(), + locale$: new Subject(), + }); + + TestBed.configureTestingModule({ + providers: [ + DesktopLoginApprovalComponentService, + { provide: I18nServiceAbstraction, useValue: i18nService }, + ], + }); + + service = TestBed.inject(DesktopLoginApprovalComponentService); + }); + + afterEach(() => { + jest.clearAllMocks(); + (global as any).ipc = originalIpc; + }); + + it("is created successfully", () => { + expect(service).toBeTruthy(); + }); + + it("calls ipc.auth.loginRequest with correct parameters when window is not visible", async () => { + const title = "Log in requested"; + const email = "test@bitwarden.com"; + const message = `Confirm login attempt for ${email}`; + const closeText = "Close"; + + const loginApprovalComponent = { email } as LoginApprovalComponent; + i18nService.t.mockImplementation((key: string) => { + switch (key) { + case "logInRequested": + return title; + case "confirmLoginAtemptForMail": + return message; + case "close": + return closeText; + default: + return ""; + } + }); + + jest.spyOn(ipc.platform, "isWindowVisible").mockResolvedValue(false); + jest.spyOn(ipc.auth, "loginRequest").mockResolvedValue(); + + await service.showLoginRequestedAlertIfWindowNotVisible(loginApprovalComponent.email); + + expect(ipc.auth.loginRequest).toHaveBeenCalledWith(title, message, closeText); + }); + + it("does not call ipc.auth.loginRequest when window is visible", async () => { + const loginApprovalComponent = { email: "test@bitwarden.com" } as LoginApprovalComponent; + + jest.spyOn(ipc.platform, "isWindowVisible").mockResolvedValue(true); + jest.spyOn(ipc.auth, "loginRequest"); + + await service.showLoginRequestedAlertIfWindowNotVisible(loginApprovalComponent.email); + + expect(ipc.auth.loginRequest).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/desktop/src/auth/login/desktop-login-approval-component.service.ts b/apps/desktop/src/auth/login/desktop-login-approval-component.service.ts new file mode 100644 index 000000000000..3e658f9ba012 --- /dev/null +++ b/apps/desktop/src/auth/login/desktop-login-approval-component.service.ts @@ -0,0 +1,26 @@ +import { Injectable } from "@angular/core"; + +import { DefaultLoginApprovalComponentService } from "@bitwarden/auth/angular"; +import { LoginApprovalComponentServiceAbstraction } from "@bitwarden/auth/common"; +import { I18nService as I18nServiceAbstraction } from "@bitwarden/common/platform/abstractions/i18n.service"; + +@Injectable() +export class DesktopLoginApprovalComponentService + extends DefaultLoginApprovalComponentService + implements LoginApprovalComponentServiceAbstraction +{ + constructor(private i18nService: I18nServiceAbstraction) { + super(); + } + + async showLoginRequestedAlertIfWindowNotVisible(email: string): Promise { + const isVisible = await ipc.platform.isWindowVisible(); + if (!isVisible) { + await ipc.auth.loginRequest( + this.i18nService.t("logInRequested"), + this.i18nService.t("confirmLoginAtemptForMail", email), + this.i18nService.t("close"), + ); + } + } +} diff --git a/libs/auth/src/angular/index.ts b/libs/auth/src/angular/index.ts index 16ae77e937f3..a01b8849c8d7 100644 --- a/libs/auth/src/angular/index.ts +++ b/libs/auth/src/angular/index.ts @@ -66,3 +66,7 @@ export * from "./vault-timeout-input/vault-timeout-input.component"; // self hosted environment configuration dialog export * from "./self-hosted-env-config-dialog/self-hosted-env-config-dialog.component"; + +// login approval +export * from "./login-approval/login-approval.component"; +export * from "./login-approval/default-login-approval-component.service"; diff --git a/libs/auth/src/angular/login-approval/default-login-approval-component.service.spec.ts b/libs/auth/src/angular/login-approval/default-login-approval-component.service.spec.ts new file mode 100644 index 000000000000..ec274fac8bc2 --- /dev/null +++ b/libs/auth/src/angular/login-approval/default-login-approval-component.service.spec.ts @@ -0,0 +1,25 @@ +import { TestBed } from "@angular/core/testing"; + +import { DefaultLoginApprovalComponentService } from "./default-login-approval-component.service"; +import { LoginApprovalComponent } from "./login-approval.component"; + +describe("DefaultLoginApprovalComponentService", () => { + let service: DefaultLoginApprovalComponentService; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [DefaultLoginApprovalComponentService], + }); + + service = TestBed.inject(DefaultLoginApprovalComponentService); + }); + + it("is created successfully", () => { + expect(service).toBeTruthy(); + }); + + it("has showLoginRequestedAlertIfWindowNotVisible method that is a no-op", async () => { + const loginApprovalComponent = {} as LoginApprovalComponent; + await service.showLoginRequestedAlertIfWindowNotVisible(loginApprovalComponent.email); + }); +}); diff --git a/libs/auth/src/angular/login-approval/default-login-approval-component.service.ts b/libs/auth/src/angular/login-approval/default-login-approval-component.service.ts new file mode 100644 index 000000000000..8b0463be6c18 --- /dev/null +++ b/libs/auth/src/angular/login-approval/default-login-approval-component.service.ts @@ -0,0 +1,16 @@ +import { LoginApprovalComponentServiceAbstraction } from "../../common/abstractions/login-approval-component.service.abstraction"; + +/** + * Default implementation of the LoginApprovalComponentServiceAbstraction. + */ +export class DefaultLoginApprovalComponentService + implements LoginApprovalComponentServiceAbstraction +{ + /** + * No-op implementation of the showLoginRequestedAlertIfWindowNotVisible method. + * @returns + */ + async showLoginRequestedAlertIfWindowNotVisible(email?: string): Promise { + return; + } +} diff --git a/apps/desktop/src/auth/login/login-approval.component.html b/libs/auth/src/angular/login-approval/login-approval.component.html similarity index 93% rename from apps/desktop/src/auth/login/login-approval.component.html rename to libs/auth/src/angular/login-approval/login-approval.component.html index cc2c0536c9e5..ddbc48d71a39 100644 --- a/apps/desktop/src/auth/login/login-approval.component.html +++ b/libs/auth/src/angular/login-approval/login-approval.component.html @@ -1,7 +1,7 @@ {{ "areYouTryingtoLogin" | i18n }} -

{{ "logInAttemptBy" | i18n: email }}

+

{{ "logInAttemptBy" | i18n: email }}

{{ "fingerprintPhraseHeader" | i18n }}

{{ fingerprintPhrase }}

diff --git a/libs/auth/src/angular/login-approval/login-approval.component.spec.ts b/libs/auth/src/angular/login-approval/login-approval.component.spec.ts new file mode 100644 index 000000000000..ff598bdeb917 --- /dev/null +++ b/libs/auth/src/angular/login-approval/login-approval.component.spec.ts @@ -0,0 +1,122 @@ +import { DialogRef, DIALOG_DATA } from "@angular/cdk/dialog"; +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { mock, MockProxy } from "jest-mock-extended"; +import { of } from "rxjs"; + +import { + AuthRequestServiceAbstraction, + LoginApprovalComponentServiceAbstraction, +} from "@bitwarden/auth/common"; +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { AuthRequestResponse } from "@bitwarden/common/auth/models/response/auth-request.response"; +import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { UserId } from "@bitwarden/common/types/guid"; +import { ToastService } from "@bitwarden/components"; +import { KeyService } from "@bitwarden/key-management"; + +import { LoginApprovalComponent } from "./login-approval.component"; + +describe("LoginApprovalComponent", () => { + let component: LoginApprovalComponent; + let fixture: ComponentFixture; + + let authRequestService: MockProxy; + let accountService: MockProxy; + let apiService: MockProxy; + let i18nService: MockProxy; + let dialogRef: MockProxy; + let toastService: MockProxy; + + const testNotificationId = "test-notification-id"; + const testEmail = "test@bitwarden.com"; + const testPublicKey = "test-public-key"; + + beforeEach(async () => { + authRequestService = mock(); + accountService = mock(); + apiService = mock(); + i18nService = mock(); + dialogRef = mock(); + toastService = mock(); + + accountService.activeAccount$ = of({ + email: testEmail, + id: "test-user-id" as UserId, + emailVerified: true, + name: null, + }); + + await TestBed.configureTestingModule({ + imports: [LoginApprovalComponent], + providers: [ + { provide: DIALOG_DATA, useValue: { notificationId: testNotificationId } }, + { provide: AuthRequestServiceAbstraction, useValue: authRequestService }, + { provide: AccountService, useValue: accountService }, + { provide: PlatformUtilsService, useValue: mock() }, + { provide: I18nService, useValue: i18nService }, + { provide: ApiService, useValue: apiService }, + { provide: AppIdService, useValue: mock() }, + { provide: KeyService, useValue: mock() }, + { provide: DialogRef, useValue: dialogRef }, + { provide: ToastService, useValue: toastService }, + { + provide: LoginApprovalComponentServiceAbstraction, + useValue: mock(), + }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(LoginApprovalComponent); + component = fixture.componentInstance; + }); + + it("creates successfully", () => { + expect(component).toBeTruthy(); + }); + + describe("ngOnInit", () => { + beforeEach(() => { + apiService.getAuthRequest.mockResolvedValue({ + publicKey: testPublicKey, + creationDate: new Date().toISOString(), + } as AuthRequestResponse); + authRequestService.getFingerprintPhrase.mockResolvedValue("test-phrase"); + }); + + it("retrieves and sets auth request data", async () => { + await component.ngOnInit(); + + expect(apiService.getAuthRequest).toHaveBeenCalledWith(testNotificationId); + expect(component.email).toBe(testEmail); + expect(component.fingerprintPhrase).toBeDefined(); + }); + + it("updates time text initially", async () => { + i18nService.t.mockReturnValue("justNow"); + + await component.ngOnInit(); + expect(component.requestTimeText).toBe("justNow"); + }); + }); + + describe("denyLogin", () => { + it("denies auth request and shows info toast", async () => { + const response = { requestApproved: false } as AuthRequestResponse; + apiService.getAuthRequest.mockResolvedValue(response); + authRequestService.approveOrDenyAuthRequest.mockResolvedValue(response); + i18nService.t.mockReturnValue("denied message"); + + await component.denyLogin(); + + expect(authRequestService.approveOrDenyAuthRequest).toHaveBeenCalledWith(false, response); + expect(toastService.showToast).toHaveBeenCalledWith({ + variant: "info", + title: null, + message: "denied message", + }); + }); + }); +}); diff --git a/apps/desktop/src/auth/login/login-approval.component.ts b/libs/auth/src/angular/login-approval/login-approval.component.ts similarity index 94% rename from apps/desktop/src/auth/login/login-approval.component.ts rename to libs/auth/src/angular/login-approval/login-approval.component.ts index e6428e0020c5..9dff4d3e27a4 100644 --- a/apps/desktop/src/auth/login/login-approval.component.ts +++ b/libs/auth/src/angular/login-approval/login-approval.component.ts @@ -4,7 +4,10 @@ import { Component, OnInit, OnDestroy, Inject } from "@angular/core"; import { Subject, firstValueFrom, map } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; -import { AuthRequestServiceAbstraction } from "@bitwarden/auth/common"; +import { + AuthRequestServiceAbstraction, + LoginApprovalComponentServiceAbstraction as LoginApprovalComponentService, +} from "@bitwarden/auth/common"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AuthRequestResponse } from "@bitwarden/common/auth/models/response/auth-request.response"; @@ -56,6 +59,7 @@ export class LoginApprovalComponent implements OnInit, OnDestroy { protected keyService: KeyService, private dialogRef: DialogRef, private toastService: ToastService, + private loginApprovalComponentService: LoginApprovalComponentService, ) { this.notificationId = params.notificationId; } @@ -89,14 +93,7 @@ export class LoginApprovalComponent implements OnInit, OnDestroy { this.updateTimeText(); }, RequestTimeUpdate); - const isVisible = await ipc.platform.isWindowVisible(); - if (!isVisible) { - await ipc.auth.loginRequest( - this.i18nService.t("logInRequested"), - this.i18nService.t("confirmLoginAtemptForMail", this.email), - this.i18nService.t("close"), - ); - } + this.loginApprovalComponentService.showLoginRequestedAlertIfWindowNotVisible(this.email); } } diff --git a/libs/auth/src/common/abstractions/index.ts b/libs/auth/src/common/abstractions/index.ts index 093d703b74a5..88a13b490d67 100644 --- a/libs/auth/src/common/abstractions/index.ts +++ b/libs/auth/src/common/abstractions/index.ts @@ -4,3 +4,4 @@ export * from "./login-email.service"; export * from "./login-strategy.service"; export * from "./user-decryption-options.service.abstraction"; export * from "./auth-request.service.abstraction"; +export * from "./login-approval-component.service.abstraction"; diff --git a/libs/auth/src/common/abstractions/login-approval-component.service.abstraction.ts b/libs/auth/src/common/abstractions/login-approval-component.service.abstraction.ts new file mode 100644 index 000000000000..eaa623598085 --- /dev/null +++ b/libs/auth/src/common/abstractions/login-approval-component.service.abstraction.ts @@ -0,0 +1,9 @@ +/** + * Abstraction for the LoginApprovalComponent service. + */ +export abstract class LoginApprovalComponentServiceAbstraction { + /** + * Shows a login requested alert if the window is not visible. + */ + abstract showLoginRequestedAlertIfWindowNotVisible: (email?: string) => Promise; +}