1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-10 05:13:29 +00:00

refactor(device-trust-toasts): [Auth/PM-11225] Refactor Toasts from Auth Services (#13665)

Refactor toast calls out of auth services. Toasts are now triggered by an observable emission that gets picked up by an observable pipeline in a new `DeviceTrustToastService` (libs/angular). That observable pipeline is then subscribed by by consuming the `AppComponent` for each client.
This commit is contained in:
rr-bw
2025-03-10 12:17:46 -07:00
committed by GitHub
parent 9a3481fdae
commit 0568a09212
12 changed files with 289 additions and 6 deletions

View File

@@ -1,9 +1,11 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { ChangeDetectorRef, Component, NgZone, OnDestroy, OnInit, inject } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { NavigationEnd, Router, RouterOutlet } from "@angular/router";
import { Subject, takeUntil, firstValueFrom, concatMap, filter, tap } from "rxjs";
import { DeviceTrustToastService } from "@bitwarden/angular/auth/services/device-trust-toast.service.abstraction";
import { LogoutReason } from "@bitwarden/auth/common";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
@@ -68,7 +70,10 @@ export class AppComponent implements OnInit, OnDestroy {
private animationControlService: AnimationControlService,
private biometricStateService: BiometricStateService,
private biometricsService: BiometricsService,
) {}
private deviceTrustToastService: DeviceTrustToastService,
) {
this.deviceTrustToastService.setupListeners$.pipe(takeUntilDestroyed()).subscribe();
}
async ngOnInit() {
initPopupClosedListener();

View File

@@ -10,10 +10,12 @@ import {
ViewChild,
ViewContainerRef,
} from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { Router } from "@angular/router";
import { filter, firstValueFrom, map, Subject, takeUntil, timeout, withLatestFrom } from "rxjs";
import { CollectionService } from "@bitwarden/admin-console/common";
import { DeviceTrustToastService } from "@bitwarden/angular/auth/services/device-trust-toast.service.abstraction";
import { ModalRef } from "@bitwarden/angular/components/modal/modal.ref";
import { ModalService } from "@bitwarden/angular/services/modal.service";
import { FingerprintDialogComponent, LoginApprovalComponent } from "@bitwarden/auth/angular";
@@ -157,7 +159,10 @@ export class AppComponent implements OnInit, OnDestroy {
private stateEventRunnerService: StateEventRunnerService,
private accountService: AccountService,
private organizationService: OrganizationService,
) {}
private deviceTrustToastService: DeviceTrustToastService,
) {
this.deviceTrustToastService.setupListeners$.pipe(takeUntilDestroyed()).subscribe();
}
ngOnInit() {
this.accountService.activeAccount$.pipe(takeUntil(this.destroy$)).subscribe((account) => {

View File

@@ -2,11 +2,13 @@
// @ts-strict-ignore
import { DOCUMENT } from "@angular/common";
import { Component, Inject, NgZone, OnDestroy, OnInit } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { NavigationEnd, Router } from "@angular/router";
import * as jq from "jquery";
import { Subject, filter, firstValueFrom, map, takeUntil, timeout } from "rxjs";
import { CollectionService } from "@bitwarden/admin-console/common";
import { DeviceTrustToastService } from "@bitwarden/angular/auth/services/device-trust-toast.service.abstraction";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { EventUploadService } from "@bitwarden/common/abstractions/event/event-upload.service";
import { SearchService } from "@bitwarden/common/abstractions/search.service";
@@ -95,7 +97,10 @@ export class AppComponent implements OnDestroy, OnInit {
private apiService: ApiService,
private appIdService: AppIdService,
private processReloadService: ProcessReloadServiceAbstraction,
) {}
private deviceTrustToastService: DeviceTrustToastService,
) {
this.deviceTrustToastService.setupListeners$.pipe(takeUntilDestroyed()).subscribe();
}
ngOnInit() {
this.i18nService.locale$.pipe(takeUntil(this.destroy$)).subscribe((locale) => {

View File

@@ -0,0 +1,9 @@
import { Observable } from "rxjs";
export abstract class DeviceTrustToastService {
/**
* An observable pipeline that observes any cross-application toast messages
* that need to be shown as part of the trusted device encryption (TDE) process.
*/
abstract setupListeners$: Observable<void>;
}

View File

@@ -0,0 +1,44 @@
import { merge, Observable, tap } from "rxjs";
import { AuthRequestServiceAbstraction } from "@bitwarden/auth/common";
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust.service.abstraction";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { ToastService } from "@bitwarden/components";
import { DeviceTrustToastService as DeviceTrustToastServiceAbstraction } from "./device-trust-toast.service.abstraction";
export class DeviceTrustToastService implements DeviceTrustToastServiceAbstraction {
private adminLoginApproved$: Observable<void>;
private deviceTrusted$: Observable<void>;
setupListeners$: Observable<void>;
constructor(
private authRequestService: AuthRequestServiceAbstraction,
private deviceTrustService: DeviceTrustServiceAbstraction,
private i18nService: I18nService,
private toastService: ToastService,
) {
this.adminLoginApproved$ = this.authRequestService.adminLoginApproved$.pipe(
tap(() => {
this.toastService.showToast({
variant: "success",
title: "",
message: this.i18nService.t("loginApproved"),
});
}),
);
this.deviceTrusted$ = this.deviceTrustService.deviceTrusted$.pipe(
tap(() => {
this.toastService.showToast({
variant: "success",
title: "",
message: this.i18nService.t("deviceTrusted"),
});
}),
);
this.setupListeners$ = merge(this.adminLoginApproved$, this.deviceTrusted$);
}
}

View File

@@ -0,0 +1,167 @@
import { mock, MockProxy } from "jest-mock-extended";
import { EMPTY, of } from "rxjs";
import { AuthRequestServiceAbstraction } from "@bitwarden/auth/common";
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust.service.abstraction";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { ToastService } from "@bitwarden/components";
import { DeviceTrustToastService as DeviceTrustToastServiceAbstraction } from "./device-trust-toast.service.abstraction";
import { DeviceTrustToastService } from "./device-trust-toast.service.implementation";
describe("DeviceTrustToastService", () => {
let authRequestService: MockProxy<AuthRequestServiceAbstraction>;
let deviceTrustService: MockProxy<DeviceTrustServiceAbstraction>;
let i18nService: MockProxy<I18nService>;
let toastService: MockProxy<ToastService>;
let sut: DeviceTrustToastServiceAbstraction;
beforeEach(() => {
authRequestService = mock<AuthRequestServiceAbstraction>();
deviceTrustService = mock<DeviceTrustServiceAbstraction>();
i18nService = mock<I18nService>();
toastService = mock<ToastService>();
i18nService.t.mockImplementation((key: string) => key); // just return the key that was given
});
const initService = () => {
return new DeviceTrustToastService(
authRequestService,
deviceTrustService,
i18nService,
toastService,
);
};
const loginApprovalToastOptions = {
variant: "success",
title: "",
message: "loginApproved",
};
const deviceTrustedToastOptions = {
variant: "success",
title: "",
message: "deviceTrusted",
};
describe("setupListeners$", () => {
describe("given adminLoginApproved$ emits and deviceTrusted$ emits", () => {
beforeEach(() => {
// Arrange
authRequestService.adminLoginApproved$ = of(undefined);
deviceTrustService.deviceTrusted$ = of(undefined);
sut = initService();
});
it("should trigger a toast for login approval", (done) => {
// Act
sut.setupListeners$.subscribe({
complete: () => {
expect(toastService.showToast).toHaveBeenCalledWith(loginApprovalToastOptions); // Assert
done();
},
});
});
it("should trigger a toast for device trust", (done) => {
// Act
sut.setupListeners$.subscribe({
complete: () => {
expect(toastService.showToast).toHaveBeenCalledWith(deviceTrustedToastOptions); // Assert
done();
},
});
});
});
describe("given adminLoginApproved$ emits and deviceTrusted$ does not emit", () => {
beforeEach(() => {
// Arrange
authRequestService.adminLoginApproved$ = of(undefined);
deviceTrustService.deviceTrusted$ = EMPTY;
sut = initService();
});
it("should trigger a toast for login approval", (done) => {
// Act
sut.setupListeners$.subscribe({
complete: () => {
expect(toastService.showToast).toHaveBeenCalledWith(loginApprovalToastOptions); // Assert
done();
},
});
});
it("should NOT trigger a toast for device trust", (done) => {
// Act
sut.setupListeners$.subscribe({
complete: () => {
expect(toastService.showToast).not.toHaveBeenCalledWith(deviceTrustedToastOptions); // Assert
done();
},
});
});
});
describe("given adminLoginApproved$ does not emit and deviceTrusted$ emits", () => {
beforeEach(() => {
// Arrange
authRequestService.adminLoginApproved$ = EMPTY;
deviceTrustService.deviceTrusted$ = of(undefined);
sut = initService();
});
it("should NOT trigger a toast for login approval", (done) => {
// Act
sut.setupListeners$.subscribe({
complete: () => {
expect(toastService.showToast).not.toHaveBeenCalledWith(loginApprovalToastOptions); // Assert
done();
},
});
});
it("should trigger a toast for device trust", (done) => {
// Act
sut.setupListeners$.subscribe({
complete: () => {
expect(toastService.showToast).toHaveBeenCalledWith(deviceTrustedToastOptions); // Assert
done();
},
});
});
});
describe("given adminLoginApproved$ does not emit and deviceTrusted$ does not emit", () => {
beforeEach(() => {
// Arrange
authRequestService.adminLoginApproved$ = EMPTY;
deviceTrustService.deviceTrusted$ = EMPTY;
sut = initService();
});
it("should NOT trigger a toast for login approval", (done) => {
// Act
sut.setupListeners$.subscribe({
complete: () => {
expect(toastService.showToast).not.toHaveBeenCalledWith(loginApprovalToastOptions); // Assert
done();
},
});
});
it("should NOT trigger a toast for device trust", (done) => {
// Act
sut.setupListeners$.subscribe({
complete: () => {
expect(toastService.showToast).not.toHaveBeenCalledWith(deviceTrustedToastOptions); // Assert
done();
},
});
});
});
});
});

View File

@@ -317,6 +317,8 @@ import {
IndividualVaultExportServiceAbstraction,
} from "@bitwarden/vault-export-core";
import { DeviceTrustToastService as DeviceTrustToastServiceAbstraction } from "../auth/services/device-trust-toast.service.abstraction";
import { DeviceTrustToastService } from "../auth/services/device-trust-toast.service.implementation";
import { FormValidationErrorsService as FormValidationErrorsServiceAbstraction } from "../platform/abstractions/form-validation-errors.service";
import { ViewCacheService } from "../platform/abstractions/view-cache.service";
import { FormValidationErrorsService } from "../platform/services/form-validation-errors.service";
@@ -1463,6 +1465,16 @@ const safeProviders: SafeProvider[] = [
useClass: DefaultTaskService,
deps: [StateProvider, ApiServiceAbstraction, OrganizationServiceAbstraction, ConfigService],
}),
safeProvider({
provide: DeviceTrustToastServiceAbstraction,
useClass: DeviceTrustToastService,
deps: [
AuthRequestServiceAbstraction,
DeviceTrustServiceAbstraction,
I18nServiceAbstraction,
ToastService,
],
}),
];
@NgModule({

View File

@@ -12,6 +12,12 @@ export abstract class AuthRequestServiceAbstraction {
/** Emits an auth request id when an auth request has been approved. */
authRequestPushNotification$: Observable<string>;
/**
* Emits when a login has been approved by an admin. This emission is specifically for the
* purpose of notifying the consuming component to display a toast informing the user.
*/
adminLoginApproved$: Observable<void>;
/**
* Returns an admin auth request for the given user if it exists.
* @param userId The user id.
@@ -106,4 +112,13 @@ export abstract class AuthRequestServiceAbstraction {
* @returns The dash-delimited fingerprint phrase.
*/
abstract getFingerprintPhrase(email: string, publicKey: Uint8Array): Promise<string>;
/**
* Passes a value to the adminLoginApprovedSubject via next(), which causes the
* adminLoginApproved$ observable to emit.
*
* The purpose is to notify consuming components (of adminLoginApproved$) to display
* a toast informing the user that a login has been approved by an admin.
*/
abstract emitAdminLoginApproved(): void;
}

View File

@@ -278,7 +278,8 @@ export class SsoLoginStrategy extends LoginStrategy {
// TODO: eventually we post and clean up DB as well once consumed on client
await this.authRequestService.clearAdminAuthRequest(userId);
this.platformUtilsService.showToast("success", null, this.i18nService.t("loginApproved"));
// This notification will be picked up by the SsoComponent to handle displaying a toast to the user
this.authRequestService.emitAdminLoginApproved();
}
}
}

View File

@@ -43,6 +43,10 @@ export class AuthRequestService implements AuthRequestServiceAbstraction {
private authRequestPushNotificationSubject = new Subject<string>();
authRequestPushNotification$: Observable<string>;
// Observable emission is used to trigger a toast in consuming components
private adminLoginApprovedSubject = new Subject<void>();
adminLoginApproved$: Observable<void>;
constructor(
private appIdService: AppIdService,
private accountService: AccountService,
@@ -53,6 +57,7 @@ export class AuthRequestService implements AuthRequestServiceAbstraction {
private stateProvider: StateProvider,
) {
this.authRequestPushNotification$ = this.authRequestPushNotificationSubject.asObservable();
this.adminLoginApproved$ = this.adminLoginApprovedSubject.asObservable();
}
async getAdminAuthRequest(userId: UserId): Promise<AdminAuthRequestStorable | null> {
@@ -207,4 +212,8 @@ export class AuthRequestService implements AuthRequestServiceAbstraction {
async getFingerprintPhrase(email: string, publicKey: Uint8Array): Promise<string> {
return (await this.keyService.getFingerprint(email.toLowerCase(), publicKey)).join("-");
}
emitAdminLoginApproved(): void {
this.adminLoginApprovedSubject.next();
}
}

View File

@@ -16,6 +16,12 @@ export abstract class DeviceTrustServiceAbstraction {
*/
supportsDeviceTrust$: Observable<boolean>;
/**
* Emits when a device has been trusted. This emission is specifically for the purpose of notifying
* the consuming component to display a toast informing the user the device has been trusted.
*/
deviceTrusted$: Observable<void>;
/**
* @description Checks if the device trust feature is supported for the given user.
*/

View File

@@ -1,6 +1,6 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { firstValueFrom, map, Observable } from "rxjs";
import { firstValueFrom, map, Observable, Subject } from "rxjs";
import { UserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common";
import { KeyService } from "@bitwarden/key-management";
@@ -63,6 +63,10 @@ export class DeviceTrustService implements DeviceTrustServiceAbstraction {
supportsDeviceTrust$: Observable<boolean>;
// Observable emission is used to trigger a toast in consuming components
private deviceTrustedSubject = new Subject<void>();
deviceTrusted$ = this.deviceTrustedSubject.asObservable();
constructor(
private keyGenerationService: KeyGenerationService,
private cryptoFunctionService: CryptoFunctionService,
@@ -177,7 +181,8 @@ export class DeviceTrustService implements DeviceTrustServiceAbstraction {
// store device key in local/secure storage if enc keys posted to server successfully
await this.setDeviceKey(userId, deviceKey);
this.platformUtilsService.showToast("success", null, this.i18nService.t("deviceTrusted"));
// This emission will be picked up by consuming components to handle displaying a toast to the user
this.deviceTrustedSubject.next();
return deviceResponse;
}