1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-11 22:13:32 +00:00
Files
browser/apps/browser/src/popup/app.component.ts
Maciej Zieniuk 63cdae92be [PM-2348] Close popup window before process reload (#16795)
* Close popup window before process reload

* unit test coverage
2025-10-21 19:06:55 +02:00

405 lines
16 KiB
TypeScript

// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import {
ChangeDetectorRef,
Component,
DestroyRef,
inject,
NgZone,
OnDestroy,
OnInit,
} from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { NavigationEnd, Router, RouterOutlet } from "@angular/router";
import {
catchError,
concatMap,
distinctUntilChanged,
filter,
firstValueFrom,
map,
of,
pairwise,
startWith,
Subject,
switchMap,
take,
takeUntil,
tap,
} from "rxjs";
import { LoginApprovalDialogComponent } from "@bitwarden/angular/auth/login-approval/login-approval-dialog.component";
import { DeviceTrustToastService } from "@bitwarden/angular/auth/services/device-trust-toast.service.abstraction";
import { DocumentLangSetter } from "@bitwarden/angular/platform/i18n";
import {
AuthRequestServiceAbstraction,
LogoutReason,
UserDecryptionOptionsServiceAbstraction,
} from "@bitwarden/auth/common";
import { BrowserApi } from "@bitwarden/browser/platform/browser/browser-api";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthRequestAnsweringServiceAbstraction } from "@bitwarden/common/auth/abstractions/auth-request-answering/auth-request-answering.service.abstraction";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { TokenService } from "@bitwarden/common/auth/abstractions/token.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { PendingAuthRequestsStateService } from "@bitwarden/common/auth/services/auth-request-answering/pending-auth-requests.state";
import { AnimationControlService } from "@bitwarden/common/platform/abstractions/animation-control.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { SdkService } from "@bitwarden/common/platform/abstractions/sdk/sdk.service";
import { MessageListener } from "@bitwarden/common/platform/messaging";
import { UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import {
DialogService,
SimpleDialogOptions,
ToastOptions,
ToastService,
} from "@bitwarden/components";
import { BiometricsService, BiometricStateService, KeyService } from "@bitwarden/key-management";
import BrowserPopupUtils from "../platform/browser/browser-popup-utils";
import { PopupCompactModeService } from "../platform/popup/layout/popup-compact-mode.service";
import { PopupSizeService } from "../platform/popup/layout/popup-size.service";
import { initPopupClosedListener } from "../platform/services/popup-view-cache-background.service";
import { routerTransition } from "./app-routing.animations";
import { DesktopSyncVerificationDialogComponent } from "./components/desktop-sync-verification-dialog.component";
@Component({
selector: "app-root",
styles: [],
animations: [routerTransition],
templateUrl: "app.component.html",
standalone: false,
})
export class AppComponent implements OnInit, OnDestroy {
private compactModeService = inject(PopupCompactModeService);
private sdkService = inject(SdkService);
private lastActivity: Date;
private activeUserId: UserId;
private routerAnimations = false;
private processingPendingAuth = false;
private destroy$ = new Subject<void>();
// Show a warning if the SDK is not available.
protected showSdkWarning = this.sdkService.client$.pipe(
map(() => false),
catchError(() => of(true)),
);
constructor(
private authService: AuthService,
private i18nService: I18nService,
private router: Router,
private readonly tokenService: TokenService,
private cipherService: CipherService,
private changeDetectorRef: ChangeDetectorRef,
private ngZone: NgZone,
private platformUtilsService: PlatformUtilsService,
private dialogService: DialogService,
private messageListener: MessageListener,
private toastService: ToastService,
private accountService: AccountService,
private animationControlService: AnimationControlService,
private biometricStateService: BiometricStateService,
private biometricsService: BiometricsService,
private deviceTrustToastService: DeviceTrustToastService,
private userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction,
private keyService: KeyService,
private readonly destoryRef: DestroyRef,
private readonly documentLangSetter: DocumentLangSetter,
private popupSizeService: PopupSizeService,
private logService: LogService,
private authRequestService: AuthRequestServiceAbstraction,
private pendingAuthRequestsState: PendingAuthRequestsStateService,
private authRequestAnsweringService: AuthRequestAnsweringServiceAbstraction,
) {
this.deviceTrustToastService.setupListeners$.pipe(takeUntilDestroyed()).subscribe();
const langSubscription = this.documentLangSetter.start();
this.destoryRef.onDestroy(() => langSubscription.unsubscribe());
}
async ngOnInit() {
initPopupClosedListener();
this.compactModeService.init();
await this.popupSizeService.setHeight();
this.accountService.activeAccount$.pipe(takeUntil(this.destroy$)).subscribe((account) => {
this.activeUserId = account?.id;
});
// Trigger processing auth requests when the active user is in an unlocked state. Runs once when
// the popup is open.
this.accountService.activeAccount$
.pipe(
map((a) => a?.id), // Extract active userId
distinctUntilChanged(), // Only when userId actually changes
filter((userId) => userId != null), // Require a valid userId
switchMap((userId) => this.authService.authStatusFor$(userId).pipe(take(1))), // Get current auth status once for new user
filter((status) => status === AuthenticationStatus.Unlocked), // Only when the new user is Unlocked
tap(() => {
// Trigger processing when switching users while popup is open
void this.authRequestAnsweringService.processPendingAuthRequests();
}),
takeUntil(this.destroy$),
)
.subscribe();
this.authService.activeAccountStatus$
.pipe(
filter((status) => status === AuthenticationStatus.Unlocked),
concatMap(async () => {
await this.recordActivity();
}),
takeUntil(this.destroy$),
)
.subscribe();
// When the popup is already open and the active account transitions to Unlocked,
// process any pending auth requests for the active user. The above subscription does not handle
// this case.
this.authService.activeAccountStatus$
.pipe(
startWith(null as unknown as AuthenticationStatus), // Seed previous value to handle initial emission
pairwise(), // Compare previous and current statuses
filter(
([prev, curr]) =>
prev !== AuthenticationStatus.Unlocked && curr === AuthenticationStatus.Unlocked, // Fire on transitions into Unlocked (incl. initial)
),
takeUntil(this.destroy$),
)
.subscribe(() => {
void this.authRequestAnsweringService.processPendingAuthRequests();
});
this.ngZone.runOutsideAngular(() => {
window.onmousedown = () => this.recordActivity();
window.ontouchstart = () => this.recordActivity();
window.onclick = () => this.recordActivity();
window.onscroll = () => this.recordActivity();
window.onkeypress = () => this.recordActivity();
});
this.messageListener.allMessages$
.pipe(
tap(async (msg: any) => {
if (msg.command === "doneLoggingOut") {
// TODO: PM-8544 - why do we call logout in the popup after receiving the doneLoggingOut message? Hasn't this already completeted logout?
this.authService.logOut(async () => {
if (msg.logoutReason) {
await this.displayLogoutReason(msg.logoutReason);
}
});
this.changeDetectorRef.detectChanges();
} else if (msg.command === "authBlocked" || msg.command === "goHome") {
await this.router.navigate(["login"]);
} else if (msg.command === "locked") {
if (msg.userId == null) {
this.logService.error("'locked' message received without userId.");
return;
}
if (msg.userId !== this.activeUserId) {
this.logService.error(
`'locked' message received with userId ${msg.userId} but active userId is ${this.activeUserId}.`,
);
return;
}
await this.biometricsService.setShouldAutopromptNow(false);
// When user is locked, normally we can just send them the lock screen.
// However, for account switching scenarios, we need to consider the TDE lock state.
const tdeEnabled = await firstValueFrom(
this.userDecryptionOptionsService
.userDecryptionOptionsById$(msg.userId)
.pipe(map((decryptionOptions) => decryptionOptions?.trustedDeviceOption != null)),
);
const everHadUserKey = await firstValueFrom(
this.keyService.everHadUserKey$(msg.userId),
);
if (tdeEnabled && !everHadUserKey) {
await this.router.navigate(["login-initiated"]);
return;
}
await this.router.navigate(["lock"]);
} else if (msg.command === "openLoginApproval") {
if (this.processingPendingAuth) {
return;
}
this.processingPendingAuth = true;
try {
// Always query server for all pending requests and open a dialog for each
const pendingList = await firstValueFrom(
this.authRequestService.getPendingAuthRequests$(),
);
if (Array.isArray(pendingList) && pendingList.length > 0) {
const respondedIds = new Set<string>();
for (const req of pendingList) {
if (req?.id == null) {
continue;
}
const dialogRef = LoginApprovalDialogComponent.open(this.dialogService, {
notificationId: req.id,
});
const result = await firstValueFrom(dialogRef.closed);
if (result !== undefined && typeof result === "boolean") {
respondedIds.add(req.id);
if (respondedIds.size === pendingList.length && this.activeUserId != null) {
await this.pendingAuthRequestsState.clear(this.activeUserId);
}
}
}
}
} finally {
this.processingPendingAuth = false;
}
} else if (msg.command === "showDialog") {
// 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.showDialog(msg);
} else if (msg.command === "showNativeMessagingFingerprintDialog") {
// TODO: Should be refactored to live in another service.
// 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.showNativeMessagingFingerprintDialog(msg);
} else if (msg.command === "showUpdateDesktopAppOrDisableFingerprintDialog") {
// TODO: Should be refactored to live in another service.
await this.showDialog({
title: this.i18nService.t("updateDesktopAppOrDisableFingerprintDialogTitle"),
content: this.i18nService.t("updateDesktopAppOrDisableFingerprintDialogMessage"),
type: "warning",
});
} else if (msg.command === "showToast") {
this.toastService._showToast(msg);
} else if (msg.command === "reloadProcess") {
if (this.platformUtilsService.isSafari()) {
window.setTimeout(async () => {
await this.biometricStateService.updateLastProcessReload();
window.location.reload();
}, 2000);
} else {
// Close browser action popup before extension reload to prevent zombie popup with invalidated context.
// This issue occurs in Chromium-based browsers (Chrome, Vivaldi, etc.) where chrome.runtime.reload()
// invalidates extension contexts before popup can close naturally
if (BrowserPopupUtils.inPopup(window)) {
BrowserApi.closePopup(window);
}
}
} else if (msg.command === "reloadPopup") {
// 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.router.navigate(["/"]);
} else if (msg.command === "convertAccountToKeyConnector") {
// 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.router.navigate(["/remove-password"]);
}
}),
takeUntil(this.destroy$),
)
.subscribe();
// eslint-disable-next-line rxjs/no-async-subscribe
this.router.events.pipe(takeUntil(this.destroy$)).subscribe(async (event) => {
if (event instanceof NavigationEnd) {
const url = event.urlAfterRedirects || event.url || "";
if (url.startsWith("/tabs/")) {
await this.cipherService.setAddEditCipherInfo(null, this.activeUserId);
}
(window as any).previousPopupUrl = url;
// Clear route direction after animation (400ms)
if ((window as any).routeDirection != null) {
window.setTimeout(() => {
(window as any).routeDirection = null;
}, 400);
}
}
});
this.animationControlService.enableRoutingAnimation$
.pipe(takeUntil(this.destroy$))
.subscribe((state) => {
this.routerAnimations = state;
});
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
getRouteElevation(outlet: RouterOutlet) {
if (!this.routerAnimations) {
return;
}
return outlet.activatedRouteData.elevation;
}
private async recordActivity() {
if (this.activeUserId == null) {
return;
}
const now = new Date();
if (this.lastActivity != null && now.getTime() - this.lastActivity.getTime() < 250) {
return;
}
this.lastActivity = now;
await this.accountService.setAccountActivity(this.activeUserId, now);
}
private showToast(msg: any) {
this.platformUtilsService.showToast(msg.type, msg.title, msg.text, msg.options);
}
private async showDialog(msg: SimpleDialogOptions) {
await this.dialogService.openSimpleDialog(msg);
}
private async showNativeMessagingFingerprintDialog(msg: any) {
const dialogRef = DesktopSyncVerificationDialogComponent.open(this.dialogService, {
fingerprint: msg.fingerprint,
});
return firstValueFrom(dialogRef.closed);
}
// Displaying toasts isn't super useful on the popup due to the reloads we do.
// However, it is visible for a moment on the FF sidebar logout.
private async displayLogoutReason(logoutReason: LogoutReason) {
let toastOptions: ToastOptions | null = null;
switch (logoutReason) {
case "invalidSecurityStamp":
case "sessionExpired": {
toastOptions = {
variant: "warning",
title: this.i18nService.t("loggedOut"),
message: this.i18nService.t("loginExpired"),
};
break;
}
}
if (toastOptions == null) {
// We don't have anything to show for this particular reason
return;
}
this.toastService.showToast(toastOptions);
}
}