mirror of
https://github.com/bitwarden/browser
synced 2025-12-17 00:33:44 +00:00
refactor(login-approval-component) [Auth/PM-14940] Update LoginApprovalComponent (#15511)
- Renames the `LoginApprovalComponent` to `LoginApprovalDialogComponent` - Renames the property `notificationId` to `authRequestId` for clarity - Updates text content on the component
This commit is contained in:
@@ -57,10 +57,6 @@ export * from "./sso/default-sso-component.service";
|
||||
// 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";
|
||||
|
||||
// two factor auth
|
||||
export * from "./two-factor-auth";
|
||||
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,16 +0,0 @@
|
||||
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<void> {
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
<bit-dialog>
|
||||
<span bitDialogTitle>{{ "areYouTryingToAccessYourAccount" | i18n }}</span>
|
||||
<ng-container bitDialogContent>
|
||||
<ng-container *ngIf="loading">
|
||||
<div class="tw-flex tw-items-center tw-justify-center" *ngIf="loading">
|
||||
<i class="bwi bwi-spinner bwi-spin bwi-3x" aria-hidden="true"></i>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<ng-container *ngIf="!loading">
|
||||
<h4 class="tw-mb-3">{{ "accessAttemptBy" | i18n: email }}</h4>
|
||||
<div>
|
||||
<b>{{ "fingerprintPhraseHeader" | i18n }}</b>
|
||||
<p class="tw-text-code">{{ fingerprintPhrase }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<b>{{ "deviceType" | i18n }}</b>
|
||||
<p>{{ authRequestResponse?.requestDeviceType }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<b>{{ "location" | i18n }}</b>
|
||||
<p>
|
||||
<span class="tw-capitalize">{{ authRequestResponse?.requestCountryName }} </span>
|
||||
({{ authRequestResponse?.requestIpAddress }})
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<b>{{ "time" | i18n }}</b>
|
||||
<p>{{ requestTimeText }}</p>
|
||||
</div>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
<ng-container bitDialogFooter>
|
||||
<button
|
||||
bitButton
|
||||
type="button"
|
||||
buttonType="primary"
|
||||
[bitAction]="approveLogin"
|
||||
[disabled]="loading"
|
||||
>
|
||||
{{ "confirmAccess" | i18n }}
|
||||
</button>
|
||||
<button
|
||||
bitButton
|
||||
type="button"
|
||||
buttonType="secondary"
|
||||
[bitAction]="denyLogin"
|
||||
[disabled]="loading"
|
||||
>
|
||||
{{ "denyAccess" | i18n }}
|
||||
</button>
|
||||
</ng-container>
|
||||
</bit-dialog>
|
||||
@@ -1,127 +0,0 @@
|
||||
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 { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { DialogRef, DIALOG_DATA, ToastService } from "@bitwarden/components";
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
|
||||
import { LoginApprovalComponent } from "./login-approval.component";
|
||||
|
||||
describe("LoginApprovalComponent", () => {
|
||||
let component: LoginApprovalComponent;
|
||||
let fixture: ComponentFixture<LoginApprovalComponent>;
|
||||
|
||||
let authRequestService: MockProxy<AuthRequestServiceAbstraction>;
|
||||
let accountService: MockProxy<AccountService>;
|
||||
let apiService: MockProxy<ApiService>;
|
||||
let i18nService: MockProxy<I18nService>;
|
||||
let dialogRef: MockProxy<DialogRef>;
|
||||
let toastService: MockProxy<ToastService>;
|
||||
let validationService: MockProxy<ValidationService>;
|
||||
|
||||
const testNotificationId = "test-notification-id";
|
||||
const testEmail = "test@bitwarden.com";
|
||||
const testPublicKey = "test-public-key";
|
||||
|
||||
beforeEach(async () => {
|
||||
authRequestService = mock<AuthRequestServiceAbstraction>();
|
||||
accountService = mock<AccountService>();
|
||||
apiService = mock<ApiService>();
|
||||
i18nService = mock<I18nService>();
|
||||
dialogRef = mock<DialogRef>();
|
||||
toastService = mock<ToastService>();
|
||||
validationService = mock<ValidationService>();
|
||||
|
||||
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<PlatformUtilsService>() },
|
||||
{ provide: I18nService, useValue: i18nService },
|
||||
{ provide: ApiService, useValue: apiService },
|
||||
{ provide: AppIdService, useValue: mock<AppIdService>() },
|
||||
{ provide: KeyService, useValue: mock<KeyService>() },
|
||||
{ provide: DialogRef, useValue: dialogRef },
|
||||
{ provide: ToastService, useValue: toastService },
|
||||
{ provide: ValidationService, useValue: validationService },
|
||||
{
|
||||
provide: LoginApprovalComponentServiceAbstraction,
|
||||
useValue: mock<LoginApprovalComponentServiceAbstraction>(),
|
||||
},
|
||||
],
|
||||
}).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",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,209 +0,0 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, OnInit, OnDestroy, Inject } from "@angular/core";
|
||||
import { Subject, firstValueFrom, map } from "rxjs";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
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";
|
||||
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 { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import {
|
||||
DIALOG_DATA,
|
||||
DialogRef,
|
||||
AsyncActionsModule,
|
||||
ButtonModule,
|
||||
DialogModule,
|
||||
DialogService,
|
||||
ToastService,
|
||||
} from "@bitwarden/components";
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
|
||||
const RequestTimeOut = 60000 * 15; //15 Minutes
|
||||
const RequestTimeUpdate = 60000 * 5; //5 Minutes
|
||||
|
||||
export interface LoginApprovalDialogParams {
|
||||
notificationId: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: "login-approval",
|
||||
templateUrl: "login-approval.component.html",
|
||||
imports: [CommonModule, AsyncActionsModule, ButtonModule, DialogModule, JslibModule],
|
||||
})
|
||||
export class LoginApprovalComponent implements OnInit, OnDestroy {
|
||||
loading = true;
|
||||
|
||||
notificationId: string;
|
||||
|
||||
private destroy$ = new Subject<void>();
|
||||
|
||||
email: string;
|
||||
fingerprintPhrase: string;
|
||||
authRequestResponse: AuthRequestResponse;
|
||||
interval: NodeJS.Timeout;
|
||||
requestTimeText: string;
|
||||
|
||||
constructor(
|
||||
@Inject(DIALOG_DATA) private params: LoginApprovalDialogParams,
|
||||
protected authRequestService: AuthRequestServiceAbstraction,
|
||||
protected accountService: AccountService,
|
||||
protected platformUtilsService: PlatformUtilsService,
|
||||
protected i18nService: I18nService,
|
||||
protected apiService: ApiService,
|
||||
protected appIdService: AppIdService,
|
||||
protected keyService: KeyService,
|
||||
private dialogRef: DialogRef,
|
||||
private toastService: ToastService,
|
||||
private loginApprovalComponentService: LoginApprovalComponentService,
|
||||
private validationService: ValidationService,
|
||||
) {
|
||||
this.notificationId = params.notificationId;
|
||||
}
|
||||
|
||||
async ngOnDestroy(): Promise<void> {
|
||||
clearInterval(this.interval);
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
async ngOnInit() {
|
||||
if (this.notificationId != null) {
|
||||
try {
|
||||
this.authRequestResponse = await this.apiService.getAuthRequest(this.notificationId);
|
||||
} catch (error) {
|
||||
this.validationService.showError(error);
|
||||
}
|
||||
|
||||
const publicKey = Utils.fromB64ToArray(this.authRequestResponse.publicKey);
|
||||
this.email = await firstValueFrom(
|
||||
this.accountService.activeAccount$.pipe(map((a) => a?.email)),
|
||||
);
|
||||
this.fingerprintPhrase = await this.authRequestService.getFingerprintPhrase(
|
||||
this.email,
|
||||
publicKey,
|
||||
);
|
||||
this.updateTimeText();
|
||||
|
||||
this.interval = setInterval(() => {
|
||||
this.updateTimeText();
|
||||
}, RequestTimeUpdate);
|
||||
|
||||
// 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.loginApprovalComponentService.showLoginRequestedAlertIfWindowNotVisible(this.email);
|
||||
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Strongly-typed helper to open a LoginApprovalDialog
|
||||
* @param dialogService Instance of the dialog service that will be used to open the dialog
|
||||
* @param data Configuration for the dialog
|
||||
*/
|
||||
static open(dialogService: DialogService, data: LoginApprovalDialogParams) {
|
||||
return dialogService.open(LoginApprovalComponent, { data });
|
||||
}
|
||||
|
||||
denyLogin = async () => {
|
||||
await this.retrieveAuthRequestAndRespond(false);
|
||||
};
|
||||
|
||||
approveLogin = async () => {
|
||||
await this.retrieveAuthRequestAndRespond(true);
|
||||
};
|
||||
|
||||
private async retrieveAuthRequestAndRespond(approve: boolean) {
|
||||
this.authRequestResponse = await this.apiService.getAuthRequest(this.notificationId);
|
||||
if (this.authRequestResponse.requestApproved || this.authRequestResponse.responseDate != null) {
|
||||
this.toastService.showToast({
|
||||
variant: "info",
|
||||
title: null,
|
||||
message: this.i18nService.t("thisRequestIsNoLongerValid"),
|
||||
});
|
||||
} else {
|
||||
const loginResponse = await this.authRequestService.approveOrDenyAuthRequest(
|
||||
approve,
|
||||
this.authRequestResponse,
|
||||
);
|
||||
this.showResultToast(loginResponse);
|
||||
}
|
||||
|
||||
this.dialogRef.close(approve);
|
||||
}
|
||||
|
||||
showResultToast(loginResponse: AuthRequestResponse) {
|
||||
if (loginResponse.requestApproved) {
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
title: null,
|
||||
message: this.i18nService.t(
|
||||
"logInConfirmedForEmailOnDevice",
|
||||
this.email,
|
||||
loginResponse.requestDeviceType,
|
||||
),
|
||||
});
|
||||
} else {
|
||||
this.toastService.showToast({
|
||||
variant: "info",
|
||||
title: null,
|
||||
message: this.i18nService.t("youDeniedALogInAttemptFromAnotherDevice"),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
updateTimeText() {
|
||||
const requestDate = new Date(this.authRequestResponse.creationDate);
|
||||
const requestDateUTC = Date.UTC(
|
||||
requestDate.getUTCFullYear(),
|
||||
requestDate.getUTCMonth(),
|
||||
requestDate.getDate(),
|
||||
requestDate.getUTCHours(),
|
||||
requestDate.getUTCMinutes(),
|
||||
requestDate.getUTCSeconds(),
|
||||
requestDate.getUTCMilliseconds(),
|
||||
);
|
||||
|
||||
const dateNow = new Date(Date.now());
|
||||
const dateNowUTC = Date.UTC(
|
||||
dateNow.getUTCFullYear(),
|
||||
dateNow.getUTCMonth(),
|
||||
dateNow.getDate(),
|
||||
dateNow.getUTCHours(),
|
||||
dateNow.getUTCMinutes(),
|
||||
dateNow.getUTCSeconds(),
|
||||
dateNow.getUTCMilliseconds(),
|
||||
);
|
||||
|
||||
const diffInMinutes = dateNowUTC - requestDateUTC;
|
||||
|
||||
if (diffInMinutes <= RequestTimeUpdate) {
|
||||
this.requestTimeText = this.i18nService.t("justNow");
|
||||
} else if (diffInMinutes < RequestTimeOut) {
|
||||
this.requestTimeText = this.i18nService.t(
|
||||
"requestedXMinutesAgo",
|
||||
(diffInMinutes / 60000).toFixed(),
|
||||
);
|
||||
} else {
|
||||
clearInterval(this.interval);
|
||||
this.dialogRef.close();
|
||||
this.toastService.showToast({
|
||||
variant: "info",
|
||||
title: null,
|
||||
message: this.i18nService.t("loginRequestHasAlreadyExpired"),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user