I wasn't thrilled with some approaches I found including some npm packages so I spun up something pretty simple for angular. You would just import the service in your app.component and call it's init() method. The only tricky part was handling the closing of the dialog on action across tabs. It's important to note that windoweventlisteners for storage only react when storage is altered outside of the current document (aka another window or tab).
import { Injectable, NgZone, OnDestroy } from '@angular/core';
import { MatDialog, MatDialogConfig, MatDialogRef } from '@angular/material/dialog';
import { Router } from '@angular/router';
import { UserService } from '@common/services/user.service';
import { takeWhile } from 'rxjs/operators';
import { IdleTimeoutActions, IDLE_LOGOUT_TIME, IDLE_TIMEOUT_ACTION, IDLE_TIMEOUT_TIME } from './IdleTimeoutActions';
import { InactivityTimeoutModalComponent } from './inactivity-timeout-modal/inactivity-timeout-modal.component';
@Injectable({
providedIn: 'root'
})
export class IdleTimeoutService implements OnDestroy {
alive = true;
interval;
timeoutSetExpiredTime;
boundUpdateExpiredTime;
boundOnIdleTimeoutAction;
inactivityDialogRef: MatDialogRef<InactivityTimeoutModalComponent>;
dialogConfig: MatDialogConfig = {
panelClass: ['confirmation-dialog', 'l-w400'],
disableClose: true
};
dialogOpen = false;
currentStatus;
constructor(private dialog: MatDialog,
private userService: UserService,
private zone: NgZone,
private router: Router) {}
init() {
this.boundUpdateExpiredTime = this.updateExpiredTime.bind(this);
this.boundOnIdleTimeoutAction = this.onIdleTimeoutAction.bind(this);
this.userService.isLoggedIn().pipe(takeWhile(x => this.alive)).subscribe(userIsLoggedIn=> {
if(userIsLoggedIn) {
this.currentStatus = window.localStorage.getItem(IDLE_TIMEOUT_ACTION);
if(this.currentStatus === IdleTimeoutActions.LOGOUT) { // if the user is logged in, reset the idletimeoutactions to null
window.localStorage.setItem(IDLE_TIMEOUT_ACTION, null);
}
window.addEventListener('storage', this.boundOnIdleTimeoutAction); // handle dialog action events from other tabs
this.startTrackingIdleTime();
}
});
}
/**
* Starts the interval that checks localstorage to determine if the idle expired time is at it's limit
*/
startTrackingIdleTime() {
this.addListeners();
if(window.localStorage.getItem(IDLE_TIMEOUT_ACTION) !== IdleTimeoutActions.IDLE_TRIGGERED) {
this.updateExpiredTime(0);
}
if(this.interval) {
clearInterval(this.interval);
}
this.interval = setInterval(() => {
const expiredTime = parseInt(localStorage.getItem('_expiredTime'), 10);
if(expiredTime + (IDLE_LOGOUT_TIME * 1000) < Date.now()) {
this.triggerLogout();
} else if (expiredTime < Date.now()) {
if(!this.dialogOpen) {
window.localStorage.setItem(IDLE_TIMEOUT_ACTION, IdleTimeoutActions.IDLE_TRIGGERED);
this.openIdleDialog();
}
}
}, 1000);
}
triggerLogout() {
this.removeListeners();
// triggers other tabs to logout
window.localStorage.setItem(IDLE_TIMEOUT_ACTION, IdleTimeoutActions.LOGOUT);
this.dialog.closeAll();
this.userService.logout();
localStorage.setItem(IDLE_TIMEOUT_ACTION, null);
}
/**
* Update the _exporedTime localStorage variable with a new time (timeout used to throttle)
*/
updateExpiredTime(timeout = 300) {
if(window.localStorage.getItem(IDLE_TIMEOUT_ACTION) !== IdleTimeoutActions.IDLE_TRIGGERED) {
if (this.timeoutSetExpiredTime) {
clearTimeout(this.timeoutSetExpiredTime);
}
this.timeoutSetExpiredTime = setTimeout(() => {
this.zone.run(() => {
localStorage.setItem('_expiredTime', '' + (Date.now() + (IDLE_TIMEOUT_TIME * 1000)));
});
}, timeout);
}
}
addListeners() {
this.zone.runOutsideAngular(() => {
window.addEventListener('mousemove', this.boundUpdateExpiredTime);
window.addEventListener('scroll', this.boundUpdateExpiredTime);
window.addEventListener('keydown', this.boundUpdateExpiredTime);
});
}
removeListeners() {
window.removeEventListener('mousemove', this.boundUpdateExpiredTime);
window.removeEventListener('scroll', this.boundUpdateExpiredTime);
window.removeEventListener('keydown', this.boundUpdateExpiredTime);
window.removeEventListener('storage', this.boundOnIdleTimeoutAction);
clearInterval(this.interval);
}
openIdleDialog() {
this.dialogOpen = true;
this.inactivityDialogRef = this.dialog.open(InactivityTimeoutModalComponent, this.dialogConfig);
this.inactivityDialogRef.afterClosed().subscribe(action => {
if(action === IdleTimeoutActions.CONTINUE) {
this.updateExpiredTime(0);
// trigger other tabs to close the modal
localStorage.setItem(IDLE_TIMEOUT_ACTION, IdleTimeoutActions.CONTINUE);
localStorage.setItem(IDLE_TIMEOUT_ACTION, null);
} else if(action === IdleTimeoutActions.LOGOUT){
this.triggerLogout();
}
this.dialogOpen = false;
});
}
onIdleTimeoutAction = (event) => {
if (event.storageArea === localStorage) {
if(this.dialogOpen) {
const action = localStorage.getItem(IDLE_TIMEOUT_ACTION);
if(action === IdleTimeoutActions.LOGOUT) {
this.removeListeners();
this.dialog.closeAll();
this.router.navigate(['login']);
} else if (action === IdleTimeoutActions.CONTINUE) {
this.updateExpiredTime(0);
this.inactivityDialogRef?.close(IdleTimeoutActions.CONTINUE);
}
}
}
}
ngOnDestroy() {
this.removeListeners();
this.alive = false;
}
}