npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@svt_089/angular-msal-session-persistence

v2.0.2

Published

Enterprise-grade session persistence and idle detection library for Angular applications with Azure AD MSAL authentication. Automatically saves form data, detects computer sleep/wake cycles, and enforces re-authentication after idle timeout.

Readme

angular-msal-session-persistence

npm version License: MIT

Enterprise-grade idle detection and form persistence library for Angular applications with Azure AD MSAL authentication.

Table of Contents


Overview

angular-msal-session-persistence solves two critical problems for Angular applications using Azure AD MSAL authentication:

  1. Idle Detection - Detect when users are away and enforce re-authentication for security
  2. Form Persistence - Automatically save and restore form data to prevent data loss

The library uses a popup-only approach for idle re-authentication to preserve page state and form data during security checks.

Key Features

  • Zero Configuration - Import directive, forms automatically persist (no template changes)
  • Automatic Form Persistence - Saves reactive forms to sessionStorage with 500ms debounce
  • Smart Form Restoration - Restores after auth redirect, skips on manual refresh (F5)
  • Intelligent Idle Detection - Hybrid detection using Page Visibility, Window Focus, and Page Lifecycle APIs
  • Sleep/Wake Detection - Accurately detects computer sleep and enforces re-authentication
  • Event-Driven Architecture - Rich events with full context, no UI coupling
  • Bring Your Own UI - Use any dialog framework (Material, Bootstrap, custom)
  • Configurable Guards - Custom navigation dialogs for unsaved changes
  • Security-First - No automatic token refresh during idle, enforces manual re-authentication
  • Browser Event Monitoring - Saves forms before critical events (unload, visibility change, freeze)
  • Drop-in Integration - Works with existing MSAL setups (popup or redirect)

Installation

npm install angular-msal-session-persistence

Peer Dependencies

npm install @angular/common @angular/core @angular/forms @angular/router \
            @azure/msal-angular @azure/msal-browser rxjs

Minimum Versions:

  • Angular: 19.x or later
  • @azure/msal-angular: 3.x or later
  • @azure/msal-browser: 3.x or later

Quick Start

1. Configure MSAL

// app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideHttpClient } from '@angular/common/http';
import {
  BrowserCacheLocation,
  IPublicClientApplication,
  PublicClientApplication
} from '@azure/msal-browser';
import {
  MSAL_INSTANCE,
  MsalService,
  MsalGuard,
  MsalBroadcastService
} from '@azure/msal-angular';

export function MSALInstanceFactory(): IPublicClientApplication {
  return new PublicClientApplication({
    auth: {
      clientId: 'YOUR_CLIENT_ID',
      authority: 'https://login.microsoftonline.com/YOUR_TENANT_ID',
      redirectUri: window.location.origin,
      postLogoutRedirectUri: window.location.origin
    },
    cache: {
      cacheLocation: BrowserCacheLocation.SessionStorage,
      storeAuthStateInCookie: false
    }
  });
}

export const appConfig: ApplicationConfig = {
  providers: [
    provideRouter(routes),
    provideHttpClient(),
    {
      provide: MSAL_INSTANCE,
      useFactory: MSALInstanceFactory
    },
    MsalService,
    MsalGuard,
    MsalBroadcastService
  ]
};

2. Add Idle Detection

Use rich events with full context and your own UI components:

// app.component.ts
import { Component, OnInit, OnDestroy, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterOutlet } from '@angular/router';
import { Subscription } from 'rxjs';
import { filter } from 'rxjs/operators';
import {
  IdleDetectionService,
  MsalAuthService,
  IdleEventType,
  IdleUserDecision,
  IdleEvent
} from 'angular-msal-session-persistence';
import { IdleDialogComponent } from './components/idle-dialog/idle-dialog.component';

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [CommonModule, RouterOutlet, IdleDialogComponent],
  template: `
    <router-outlet></router-outlet>

    <!-- Custom dialog with rich event data -->
    <app-idle-dialog
      *ngIf="showIdleDialog && currentIdleEvent"
      [event]="currentIdleEvent"
      (decision)="onIdleDialogDecision($event)">
    </app-idle-dialog>
  `
})
export class AppComponent implements OnInit, OnDestroy {
  private idleService = inject(IdleDetectionService);
  private authService = inject(MsalAuthService);
  private subscription?: Subscription;

  showIdleDialog = false;
  currentIdleEvent?: IdleEvent;

  ngOnInit(): void {
    if (this.authService.isAuthenticated()) {
      this.startIdleDetection();
    }
  }

  private startIdleDetection(): void {
    this.idleService.configure({
      idleTimeoutSeconds: 900, // 15 minutes
      enableLogging: false
    });

    // Subscribe to rich idle events with full context
    this.subscription = this.idleService.onIdleEvent$
      .pipe(filter(e => e.type === IdleEventType.TIMEOUT_EXCEEDED))
      .subscribe((event) => {
        // Show custom dialog with event context
        this.currentIdleEvent = event;
        this.showIdleDialog = true;
      });

    this.idleService.start();
  }

  async onIdleDialogDecision(decision: IdleUserDecision): Promise<void> {
    this.showIdleDialog = false;

    if (decision === IdleUserDecision.REAUTHENTICATE) {
      try {
        await this.authService.loginPopup();
        this.idleService.reset();
      } catch (error) {
        console.error('Re-authentication failed', error);
      }
    } else if (decision === IdleUserDecision.SIGN_OUT) {
      await this.authService.logoutPopup();
    }
  }

  ngOnDestroy(): void {
    this.subscription?.unsubscribe();
    this.idleService.stop();
  }
}

3. Enable Form Persistence

Simply import the directive:

// user-form.component.ts
import { Component, OnInit, inject } from '@angular/core';
import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import { AutoPersistFormDirective } from 'angular-msal-session-persistence';

@Component({
  selector: 'app-user-form',
  standalone: true,
  imports: [
    ReactiveFormsModule,
    AutoPersistFormDirective  // Add this - that's it!
  ],
  template: `
    <form [formGroup]="userForm" (ngSubmit)="onSubmit()">
      <input formControlName="firstName" placeholder="First Name" />
      <input formControlName="lastName" placeholder="Last Name" />
      <input formControlName="email" type="email" placeholder="Email" />
      <button type="submit" [disabled]="!userForm.valid">Submit</button>
    </form>
  `
})
export class UserFormComponent implements OnInit {
  private fb = inject(FormBuilder);
  userForm!: FormGroup;

  ngOnInit(): void {
    this.userForm = this.fb.group({
      firstName: ['', Validators.required],
      lastName: ['', Validators.required],
      email: ['', [Validators.required, Validators.email]]
    });
  }

  onSubmit(): void {
    if (this.userForm.valid) {
      console.log('Form submitted:', this.userForm.value);
    }
  }
}

What happens automatically:

  • ✅ Saves to sessionStorage every 500ms after changes
  • ✅ Restores after auth popup/redirect (preserves work during re-auth)
  • ✅ Skips restore on manual refresh (F5 = fresh start)
  • ✅ Clears after successful form submission
  • ✅ Saves before page unload/visibility change

How It Works

Idle Detection Flow

  1. User switches tab / minimizes window / locks computer
  2. Idle timer starts
  3. Timer reaches timeout (default 15 minutes)
  4. onIdleEvent$ emits rich event with context
  5. Your app shows custom re-auth dialog
  6. User authenticates via popup
  7. Form data restored from sessionStorage

Detection Methods:

  • Page Visibility API - Detects tab switches
  • Window Focus/Blur - Detects minimize, lock screen
  • Page Lifecycle API - Detects computer sleep/wake (Chrome/Edge)
  • Long Inactivity Check - Fallback for missed events

Scenarios Detected:

  • ✅ Switch to another browser tab
  • ✅ Minimize browser window
  • ✅ Switch to another application (Alt+Tab)
  • ✅ Lock computer (Windows+L, Ctrl+Cmd+Q)
  • ✅ Computer sleep/hibernate
  • ✅ Virtual desktop switch
  • ✅ Focus on different monitor

Form Persistence Flow

  1. User types in form
  2. Debounce 500ms
  3. Save to sessionStorage with key sessionpersist_form_<route>_<unique>
  4. On component init, directive checks navigation type
  5. Restores if auth redirect or SPA navigation
  6. Clears if manual refresh (F5)
  7. Clears on successful form submission

Smart Form Restoration

Restores Forms When:

  • Auth Popup Returns - User completed re-authentication popup (preserves work during security check)
  • SPA Navigation - User navigates within the app, route changes
  • Browser Back/Forward - User uses browser navigation buttons

Skips Restoration When:

  • Manual Page Refresh (F5, Ctrl+R, browser refresh button) - User explicitly requested fresh start
  • First Page Load - New session starts clean

How Detection Works:

// NavigationTrackerService uses Navigation Timing API
if (sessionStorage.getItem('_auth_redirect_in_progress') === 'true') {
  return true; // Restore - auth popup return
}

const navType = performance.getEntriesByType('navigation')[0].type;
if (navType === 'reload') {
  return false; // Manual refresh - don't restore
}

return true; // Normal navigation - restore

API Reference

IdleDetectionService

class IdleDetectionService {
  // Configuration
  configure(config: Partial<IdleConfig>): void;

  // Control
  start(): void;
  stop(): void;
  reset(): void;

  // State
  isActive(): boolean;
  isUserPresent(): boolean;
  getTimeRemaining(): number; // seconds until timeout

  // Events - Rich events with full context
  readonly onIdleEvent$: Observable<IdleEvent>;
}

interface IdleConfig {
  idleTimeoutSeconds: number;         // Default: 900 (15 minutes)
  enableLogging?: boolean;            // Default: false
  onTimeoutHandler?: IdleDecisionHandler;  // Optional callback pattern
  warningThresholdSeconds?: number;   // Future feature (not yet implemented)
}

Example:

import { filter } from 'rxjs/operators';
import { IdleEventType } from 'angular-msal-session-persistence';

// Configure and start
this.idleService.configure({
  idleTimeoutSeconds: 900,
  enableLogging: true
});
this.idleService.start();

// Subscribe to rich idle events with full context
this.idleService.onIdleEvent$
  .pipe(filter(e => e.type === IdleEventType.TIMEOUT_EXCEEDED))
  .subscribe((event: IdleEvent) => {
    console.log('Idle timeout:', {
      elapsedTime: event.elapsedTimeMs,
      lastActive: event.lastActiveTimestamp,
      formsSaved: event.metadata.formsSaved,
      returnReason: event.metadata.returnReason
    });
    this.handleIdleWithContext(event);
  });

// Check state
console.log('Active:', this.idleService.isActive());
console.log('User present:', this.idleService.isUserPresent());
console.log('Time remaining:', this.idleService.getTimeRemaining());

// Reset after re-authentication
await this.authService.loginPopup();
this.idleService.reset();

// Stop on logout
this.idleService.stop();

Idle Events

Rich event objects with full context for idle timeout events.

// Event Types
enum IdleEventType {
  TIMEOUT_EXCEEDED = 'TIMEOUT_EXCEEDED',
  WARNING = 'WARNING',           // Future feature
  USER_RETURNED = 'USER_RETURNED' // Future feature
}

// Rich event object with full context
interface IdleEvent {
  type: IdleEventType;
  timestamp: Date;
  lastActiveTimestamp: Date;
  elapsedTimeMs: number;          // How long user was idle
  configuredTimeoutMs: number;    // Configured timeout value
  metadata: IdleEventMetadata;
}

// Metadata about the idle event
interface IdleEventMetadata {
  returnReason?: 'tab_visible' | 'window_focused' | 'page_resumed' | 'manual_check';
  formsSaved?: boolean;
  formCount?: number;
  [key: string]: any;  // Extensible for custom data
}

// User's decision after idle timeout
enum IdleUserDecision {
  REAUTHENTICATE = 'REAUTHENTICATE',
  SIGN_OUT = 'SIGN_OUT',
  CANCELLED = 'CANCELLED'
}

// Callback handler type (alternative to Observable pattern)
type IdleDecisionHandler = (event: IdleEvent) => Promise<IdleUserDecision | void> | IdleUserDecision | void;

Example: Using Rich Events

import { IdleEventType, IdleUserDecision } from 'angular-msal-session-persistence';
import { filter } from 'rxjs/operators';

// Subscribe to specific event types
this.idleService.onIdleEvent$
  .pipe(filter(e => e.type === IdleEventType.TIMEOUT_EXCEEDED))
  .subscribe((event: IdleEvent) => {
    console.log('Idle Event Context:', {
      elapsedMinutes: Math.floor(event.elapsedTimeMs / 60000),
      lastActive: event.lastActiveTimestamp.toLocaleTimeString(),
      howDetected: event.metadata.returnReason,
      formsAutosaved: event.metadata.formCount
    });

    // Show custom dialog with event context
    this.showCustomIdleDialog(event);
  });

Example: Using Callback Pattern

// Alternative: Configure callback handler in IdleConfig
this.idleService.configure({
  idleTimeoutSeconds: 900,
  onTimeoutHandler: async (event: IdleEvent) => {
    const result = await this.myCustomDialogService.show(event);
    return result; // IdleUserDecision
  }
});

GuardConfigService

Configure custom navigation handlers for UnsavedChangesGuard.

class GuardConfigService {
  // Register custom handler for navigation events
  setNavigationHandler(handler: NavigationDecisionHandler): void;

  // Get registered handler
  getNavigationHandler(): NavigationDecisionHandler | undefined;

  // Check if custom handler configured
  hasCustomHandler(): boolean;

  // Get default handler (window.confirm)
  getDefaultHandler(): NavigationDecisionHandler;

  // Clear registered handler
  clearNavigationHandler(): void;
}

Example: Configure Custom Navigation Dialog

import { GuardConfigService, NavigationDecision } from 'angular-msal-session-persistence';

@Component({
  selector: 'app-root',
  // ...
})
export class AppComponent implements OnInit {
  private guardConfig = inject(GuardConfigService);

  ngOnInit(): void {
    // Configure custom navigation handler
    this.guardConfig.setNavigationHandler(async (event: UnsavedChangesEvent) => {
      // Show your custom dialog
      const userWantsToProceed = await this.showCustomNavigationDialog(event);

      return userWantsToProceed
        ? NavigationDecision.PROCEED
        : NavigationDecision.STAY;
    });
  }

  private async showCustomNavigationDialog(event: UnsavedChangesEvent): Promise<boolean> {
    // Example with Material Dialog
    const dialogRef = this.dialog.open(UnsavedChangesDialogComponent, {
      data: event,
      disableClose: true
    });

    return await dialogRef.afterClosed().toPromise();
  }
}

Navigation Events

Rich event objects for navigation blocking with unsaved changes.

// Event emitted when navigation is blocked
interface UnsavedChangesEvent {
  componentName?: string;
  currentRoute: string;
  targetRoute: string;
  formSummary?: {
    changedFieldCount?: number;
    changedFields?: string[];
    [key: string]: any;
  };
  timestamp: Date;
  metadata?: {
    [key: string]: any;
  };
}

// User's navigation decision
enum NavigationDecision {
  PROCEED = 'PROCEED',  // Allow navigation (discard changes)
  STAY = 'STAY'         // Block navigation (keep changes)
}

// Callback handler type for navigation events
type NavigationDecisionHandler = (event: UnsavedChangesEvent) => Promise<NavigationDecision> | NavigationDecision;

Example: Using Navigation Events

// The guard automatically calls your configured handler
// when navigation is attempted with unsaved changes

this.guardConfig.setNavigationHandler(async (event: UnsavedChangesEvent) => {
  console.log('Navigation blocked:', {
    from: event.currentRoute,
    to: event.targetRoute,
    component: event.componentName
  });

  // Show dialog, return user decision
  const decision = await this.myDialog.show(event);
  return decision;
});

MsalAuthService

class MsalAuthService {
  // Authentication
  isAuthenticated(): boolean;
  getAccount(): AccountInfo | null;
  getAllAccounts(): AccountInfo[];
  loginPopup(scopes?: string[]): Promise<AuthenticationResult>;
  logoutPopup(account?: AccountInfo): Promise<void>;

  // Observables
  readonly isAuthenticated$: Observable<boolean>;
  readonly account$: Observable<AccountInfo | null>;
}

Example:

// Check authentication
if (this.authService.isAuthenticated()) {
  const account = this.authService.getAccount();
  console.log('Logged in as:', account?.name);
}

// Listen to auth changes
this.authService.isAuthenticated$.subscribe(isAuth => {
  if (isAuth) {
    this.startIdleDetection();
  } else {
    this.idleService.stop();
  }
});

// Login/Logout
await this.authService.loginPopup(['user.read']);
await this.authService.logoutPopup();

FormPersistenceService

class FormPersistenceService {
  // Auto-save
  enableAutoSave(form: FormGroup, key: string, debounceMs?: number): void;
  disableAutoSave(key: string): void;

  // Manual operations
  saveFormState(form: FormGroup, key: string): boolean;
  restoreFormState(form: FormGroup, key: string): boolean;
  clearFormState(key: string): void;
  clearAllFormStates(): void;

  // State checking
  hasUnsavedChanges(form: FormGroup): boolean;
  hasSavedState(key: string): boolean;
}

Example:

// Manual save/restore
this.formPersistence.saveFormState(this.myForm, 'my-form-key');
this.formPersistence.restoreFormState(this.myForm, 'my-form-key');

// Check state
if (this.formPersistence.hasSavedState('my-form-key')) {
  console.log('Form has saved data');
}

if (this.formPersistence.hasUnsavedChanges(this.myForm)) {
  console.log('Form has unsaved changes');
}

// Clear state
this.formPersistence.clearFormState('my-form-key');
this.formPersistence.clearAllFormStates();

AutoPersistFormDirective

@Directive({
  selector: '[formGroup]',  // Automatically attaches to ALL forms
  standalone: true
})
class AutoPersistFormDirective {
  @Input() autoPersist: boolean = true;   // Enable/disable
  @Input() persistKey?: string;           // Custom storage key
  @Input() debounceMs: number = 500;     // Debounce time
}

Examples:

<!-- Default: Automatic persistence -->
<form [formGroup]="myForm">
  <!-- Automatically persists -->
</form>

<!-- Disable for sensitive forms -->
<form [formGroup]="loginForm" [autoPersist]="false">
  <input formControlName="password" type="password" />
</form>

<!-- Custom storage key -->
<form [formGroup]="checkoutForm" [persistKey]="'checkout-cart'">
  <!-- Uses 'checkout-cart' instead of auto-generated key -->
</form>

<!-- Custom debounce time -->
<form [formGroup]="criticalForm" [debounceMs]="200">
  <!-- Saves 200ms after last change (faster) -->
</form>

<!-- Combine options -->
<form [formGroup]="form"
      [persistKey]="'user-profile'"
      [debounceMs]="300">
</form>

FormLifecycleService

class FormLifecycleService {
  registerForm(form: FormGroup, key: string): void;
  unregisterForm(key: string): void;
  saveAllForms(): void;
}

Example:

// Save all forms before navigation
saveBeforeNavigation(): void {
  this.formLifecycle.saveAllForms();
  this.router.navigate(['/next-page']);
}

NavigationTrackerService

class NavigationTrackerService {
  shouldRestoreForms(): boolean;
  markAuthRedirect(): void;
  clearAll(): void;
  getDebugInfo(): {
    isAuthRedirect: boolean;
    navigationType: 'navigate' | 'reload' | 'back_forward' | undefined;
    shouldRestore: boolean;
  };
}

Example (Advanced):

// Debug navigation state
const debug = this.navTracker.getDebugInfo();
console.log('Navigation type:', debug.navigationType);
console.log('Should restore:', debug.shouldRestore);

Configuration

⚠️ IMPORTANT: Multiple Forms on Same Page

CRITICAL: When you have multiple forms on the same page, you MUST provide unique keys to prevent data collisions.

❌ Wrong - Forms Will Collide

<!-- DON'T DO THIS -->
<form [formGroup]="billingForm">...</form>
<form [formGroup]="shippingForm">...</form>
<!-- Both use same key: form__checkout_default -->
<!-- Forms will overwrite each other's data! -->

The library will throw an error:

Error: Form key collision detected: form__checkout_default

✅ Correct - Each Form Has Unique Key

Option 1: Using [persistKey]

<form [formGroup]="billingForm" [persistKey]="'checkout-billing'">...</form>
<form [formGroup]="shippingForm" [persistKey]="'checkout-shipping'">...</form>

Option 2: Using HTML id attribute

<form [formGroup]="billingForm" id="billing">...</form>
<form [formGroup]="shippingForm" id="shipping">...</form>

Option 3: Using HTML name attribute

<form [formGroup]="billingForm" name="billing">...</form>
<form [formGroup]="shippingForm" name="shipping">...</form>

Form Key Generation Priority

The directive checks for identifiers in this order:

  1. [persistKey] input → Explicit control (recommended for dynamic forms)
  2. id attribute → Semantic, accessible (recommended for static forms)
  3. name attribute → Semantic, standard HTML
  4. data-persist-key attribute → Custom data attribute
  5. Deterministic hash → Automatic fallback (based on route + classes + controls + position)

Examples:

<!-- Priority 1: Explicit [persistKey] -->
<form [formGroup]="myForm" [persistKey]="'user-profile'">
  <!-- Key: form_user-profile -->
  <!-- ✅ Most explicit, full control, best for dynamic forms -->
</form>

<!-- Priority 2: HTML id -->
<form [formGroup]="myForm" id="billing-form">
  <!-- Key: form__checkout_billing-form -->
  <!-- ✅ Semantic, accessible, best for static forms -->
</form>

<!-- Priority 3: HTML name -->
<form [formGroup]="myForm" name="contact-form">
  <!-- Key: form__checkout_contact-form -->
  <!-- ✅ Standard HTML, works with form submission -->
</form>

<!-- Priority 4: data-persist-key -->
<form [formGroup]="myForm" data-persist-key="notes">
  <!-- Key: form__checkout_notes -->
  <!-- ✅ Custom attribute, explicit -->
</form>

<!-- Priority 5: Deterministic hash (fallback) -->
<form [formGroup]="myForm" class="user-form">
  <!-- Key: form__checkout_5m8k3p9n -->
  <!-- ✅ Automatic, deterministic (same form = same key) -->
  <!-- Hash based on: route + classes + control names + position -->
</form>

Deterministic Hash Fallback

When no explicit identifier is provided, the library generates a deterministic hash based on:

  1. Route - The page URL path
  2. CSS Classes - Element classes (excluding Angular internal classes)
  3. Form Controls - Names of all form controls (sorted)
  4. Position - Index among sibling <form> elements

Benefits:

  • ✅ Same form structure = same key across page reloads
  • ✅ No random numbers or counters
  • ✅ Unique even for identical forms (via position)
  • ✅ Predictable and stable

Example:

<form [formGroup]="billingForm" class="billing-form card">
  <input formControlName="address" />
  <input formControlName="city" />
</form>

<!-- Key calculation:
  Route: _checkout
  Classes: billing_form_card
  Controls: address_city
  Position: 0
  Hash: 8k3m9p5n
  Final Key: form__checkout_8k3m9p5n
-->

Console Output:

[AutoPersistFormDirective] Generated deterministic key:
  Route: _checkout
  Classes: billing_form_card
  Controls: address_city
  Position: 0
  Hash: 8k3m9p5n
  Key: form__checkout_8k3m9p5n
  Tip: Add id="unique-id" for explicit control over keys

Best Practices for Multiple Forms

<!-- ✅ BEST: Using HTML id (semantic + accessible) -->
<div class="checkout-page">
  <form [formGroup]="billingForm" id="billing">
    <h3>Billing Address</h3>
    <input formControlName="address" />
  </form>

  <form [formGroup]="shippingForm" id="shipping">
    <h3>Shipping Address</h3>
    <input formControlName="address" />
  </form>
</div>

<!-- ✅ ALSO GOOD: Using [persistKey] (explicit) -->
<div class="checkout-page">
  <form [formGroup]="billingForm" [persistKey]="'checkout-billing'">
  <form [formGroup]="shippingForm" [persistKey]="'checkout-shipping'">
</div>

<!-- ✅ WORKS: Deterministic hash with classes -->
<div class="checkout-page">
  <form [formGroup]="billingForm" class="billing-form">
    <!-- Key: form__checkout_5m8k3p9n -->
  </form>
  <form [formGroup]="shippingForm" class="shipping-form">
    <!-- Key: form__checkout_7p2k9m4q -->
    <!-- Different class = different hash -->
  </form>
</div>

Environment-Based Configuration

// environment.ts (development)
export const environment = {
  production: false,
  idleTimeout: 60,        // 1 minute for testing
  enableIdleLogging: true
};

// environment.prod.ts (production)
export const environment = {
  production: true,
  idleTimeout: 900,       // 15 minutes
  enableIdleLogging: false
};

// app.component.ts
import { environment } from '../environments/environment';

ngOnInit(): void {
  this.idleService.configure({
    idleTimeoutSeconds: environment.idleTimeout,
    enableLogging: environment.enableIdleLogging
  });
  this.idleService.start();
}

Exclude Sensitive Forms

<!-- Login form - don't persist password -->
<form [formGroup]="loginForm" [autoPersist]="false">
  <input formControlName="username" />
  <input formControlName="password" type="password" />
</form>

<!-- Payment form - don't persist card numbers -->
<form [formGroup]="paymentForm" [autoPersist]="false">
  <input formControlName="cardNumber" />
  <input formControlName="cvv" type="password" />
</form>

Advanced Usage

Custom Re-Authentication UI

Use Material Dialog or any other dialog framework with rich idle events:

// idle-dialog.component.ts
import { Component, Inject } from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { IdleEvent, IdleUserDecision } from 'angular-msal-session-persistence';

@Component({
  selector: 'app-idle-dialog',
  template: `
    <h2 mat-dialog-title>⏱️ Session Idle</h2>

    <mat-dialog-content>
      <p>Your session has been idle for {{ formatDuration(data.elapsedTimeMs) }}</p>

      <mat-list>
        <mat-list-item>
          <mat-icon matListItemIcon>schedule</mat-icon>
          <div matListItemTitle>Last Active</div>
          <div matListItemLine>{{ data.lastActiveTimestamp | date:'short' }}</div>
        </mat-list-item>

        <mat-list-item *ngIf="data.metadata.formsSaved">
          <mat-icon matListItemIcon>save</mat-icon>
          <div matListItemTitle>Forms Saved</div>
          <div matListItemLine>{{ data.metadata.formCount || 0 }} form(s)</div>
        </mat-list-item>

        <mat-list-item *ngIf="data.metadata.returnReason">
          <mat-icon matListItemIcon>info</mat-icon>
          <div matListItemTitle>Detected via</div>
          <div matListItemLine>{{ formatReturnReason(data.metadata.returnReason) }}</div>
        </mat-list-item>
      </mat-list>

      <mat-hint>Your form data has been saved and will be restored after re-authentication.</mat-hint>
    </mat-dialog-content>

    <mat-dialog-actions align="end">
      <button mat-button (click)="dialogRef.close(IdleUserDecision.SIGN_OUT)">
        Sign Out
      </button>
      <button mat-raised-button color="primary"
              (click)="dialogRef.close(IdleUserDecision.REAUTHENTICATE)">
        Re-authenticate
      </button>
    </mat-dialog-actions>
  `
})
export class IdleDialogComponent {
  IdleUserDecision = IdleUserDecision;

  constructor(
    public dialogRef: MatDialogRef<IdleDialogComponent>,
    @Inject(MAT_DIALOG_DATA) public data: IdleEvent
  ) {}

  formatDuration(ms: number): string {
    const minutes = Math.floor(ms / 60000);
    const seconds = Math.floor((ms % 60000) / 1000);
    return `${minutes}m ${seconds}s`;
  }

  formatReturnReason(reason: string): string {
    const map: Record<string, string> = {
      'tab_visible': 'Tab became visible',
      'window_focused': 'Window gained focus',
      'page_resumed': 'Page resumed from sleep',
      'manual_check': 'Manual check'
    };
    return map[reason] || reason;
  }
}

// In app.component.ts
import { MatDialog } from '@angular/material/dialog';
import { filter } from 'rxjs/operators';

ngOnInit(): void {
  this.idleService.onIdleEvent$
    .pipe(filter(e => e.type === IdleEventType.TIMEOUT_EXCEEDED))
    .subscribe(async (event: IdleEvent) => {
      // Open Material Dialog with event data
      const dialogRef = this.dialog.open(IdleDialogComponent, {
        data: event,
        disableClose: true,
        width: '500px'
      });

      const decision = await dialogRef.afterClosed().toPromise();

      if (decision === IdleUserDecision.REAUTHENTICATE) {
        try {
          await this.authService.loginPopup();
          this.idleService.reset();
        } catch (error) {
          console.error('Re-authentication failed', error);
        }
      } else if (decision === IdleUserDecision.SIGN_OUT) {
        await this.authService.logoutPopup();
      }
    });
}

Callback Pattern for Idle Events

Alternative to Observable pattern - use callback configuration:

// Configure callback handler directly in IdleConfig
this.idleService.configure({
  idleTimeoutSeconds: 900,
  enableLogging: false,
  onTimeoutHandler: async (event: IdleEvent) => {
    // Open dialog
    const dialogRef = this.dialog.open(IdleDialogComponent, {
      data: event,
      disableClose: true
    });

    // Return user decision
    const decision = await dialogRef.afterClosed().toPromise();
    return decision;
  }
});

// No need to subscribe to onIdleEvent$ - callback is invoked automatically
this.idleService.start();

This approach:

  • ✅ Cleaner for simple use cases
  • ✅ No need to manage subscriptions
  • ✅ Handler is called automatically when timeout occurs
  • ✅ Can still use onIdleEvent$ Observable if needed

Route Guards with Custom Dialogs

Use the built-in UnsavedChangesGuard with custom dialog configuration:

// Step 1: Configure custom navigation handler in app.component.ts
import {
  GuardConfigService,
  NavigationDecision,
  UnsavedChangesEvent
} from 'angular-msal-session-persistence';

@Component({ /* ... */ })
export class AppComponent implements OnInit {
  private guardConfig = inject(GuardConfigService);

  showUnsavedChangesDialog = false;
  currentNavigationEvent?: UnsavedChangesEvent;
  private navigationDecisionResolve?: (decision: NavigationDecision) => void;

  ngOnInit(): void {
    // Configure custom navigation handler
    this.guardConfig.setNavigationHandler(async (event: UnsavedChangesEvent) => {
      console.log('Navigation blocked:', event);

      // Show custom dialog
      this.currentNavigationEvent = event;
      this.showUnsavedChangesDialog = true;

      // Return promise that resolves when user decides
      return new Promise<NavigationDecision>((resolve) => {
        this.navigationDecisionResolve = resolve;
      });
    });
  }

  onNavigationDialogDecision(decision: NavigationDecision): void {
    this.showUnsavedChangesDialog = false;
    this.currentNavigationEvent = undefined;

    if (this.navigationDecisionResolve) {
      this.navigationDecisionResolve(decision);
      this.navigationDecisionResolve = undefined;
    }
  }
}

// Step 2: Use UnsavedChangesGuard in routes
import { UnsavedChangesGuard } from 'angular-msal-session-persistence';

export const routes: Routes = [
  {
    path: 'form',
    component: FormComponent,
    canDeactivate: [UnsavedChangesGuard]
  }
];

// Step 3: Implement canDeactivate in component
export class FormComponent implements ComponentWithUnsavedChanges {
  canDeactivate(): boolean {
    // Return false to block navigation (triggers custom dialog)
    return !this.form.dirty;
  }
}

With Material Dialog:

// unsaved-changes-dialog.component.ts
import { Component, Inject } from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { UnsavedChangesEvent } from 'angular-msal-session-persistence';

@Component({
  selector: 'app-unsaved-changes-dialog',
  template: `
    <h2 mat-dialog-title>⚠️ Unsaved Changes</h2>

    <mat-dialog-content>
      <p>You have unsaved changes. Discard them?</p>

      <mat-card appearance="outlined">
        <mat-card-content>
          <p><strong>From:</strong> <code>{{ data.currentRoute }}</code></p>
          <p><strong>To:</strong> <code>{{ data.targetRoute }}</code></p>
          <p *ngIf="data.componentName">
            <strong>Component:</strong> {{ data.componentName }}
          </p>
        </mat-card-content>
      </mat-card>
    </mat-dialog-content>

    <mat-dialog-actions align="end">
      <button mat-button [mat-dialog-close]="false">
        Stay on Page
      </button>
      <button mat-raised-button color="warn" [mat-dialog-close]="true">
        Discard Changes
      </button>
    </mat-dialog-actions>
  `
})
export class UnsavedChangesDialogComponent {
  constructor(
    @Inject(MAT_DIALOG_DATA) public data: UnsavedChangesEvent
  ) {}
}

// In app.component.ts
this.guardConfig.setNavigationHandler(async (event: UnsavedChangesEvent) => {
  const dialogRef = this.dialog.open(UnsavedChangesDialogComponent, {
    data: event,
    disableClose: true
  });

  const userWantsToProceed = await dialogRef.afterClosed().toPromise();

  return userWantsToProceed
    ? NavigationDecision.PROCEED
    : NavigationDecision.STAY;
});

Check Form State Before Navigation

import { FormPersistenceService } from 'angular-msal-session-persistence';

export class MyComponent {
  private formPersistence = inject(FormPersistenceService);

  canDeactivate(): boolean {
    if (this.formPersistence.hasUnsavedChanges(this.myForm)) {
      return confirm('You have unsaved changes. Leave?');
    }
    return true;
  }

  ngOnInit(): void {
    // Check if saved state exists
    if (this.formPersistence.hasSavedState('my-form-key')) {
      console.log('Form has previously saved data');
    }
  }
}

Security

What This Library Does

Monitors user presence using Page Visibility API, Window Focus, and Page Lifecycle API ✅ Detects idle timeout based on wall-clock time while user is away ✅ Shows re-auth prompt when user returns after timeout ✅ Saves form data to sessionStorage (cleared on tab close) ✅ Enforces manual re-auth via popup (preserves page state)

What This Library Does NOT Do

Never calls ssoSilent() or acquireTokenSilent() automatically ❌ Never refreshes tokens while user is idle/away ❌ Never stores tokens in localStorage (only sessionStorage) ❌ Never bypasses re-authentication requirement ❌ Never persists passwords or sensitive data (unless you explicitly enable it)

Best Practices

  1. Use sessionStorage - Tokens and form data cleared when tab closes
  2. Enforce re-authentication - No automatic token refresh during idle
  3. Exclude sensitive forms - Use [autoPersist]="false" for login/payment forms
  4. Configure appropriate timeout:
    • Development: 60 seconds (for testing)
    • Production: 900 seconds (15 minutes)
    • High Security: 300 seconds (5 minutes)

Data Storage

sessionStorage (cleared on tab close):
  ├── msal.* (MSAL tokens - managed by MSAL library)
  └── sessionpersist_form_* (Form data - managed by this library)

localStorage:
  └── (Not used by this library)

Testing

Manual Testing Checklist

  1. Login → User logs in successfully
  2. Form Fill → Fill form, see data auto-save to sessionStorage
  3. Manual Refresh → Hit F5, form should clear (fresh start)
  4. Re-Auth Popup → Complete auth popup, form should restore
  5. Form Submit → Submit form, data should clear from sessionStorage
  6. Navigate Away → Navigate to another page, unsaved changes warning (if guard enabled)
  7. Close Tab → Close tab, reopen, data should be gone
  8. Remain Idle → Wait 15 minutes, timeout dialog should appear
  9. Re-Authenticate → After idle, re-auth via popup, form preserved
  10. Computer Sleep → Close laptop, wait 15+ min, open, timeout detected

Testing Idle Detection

// Reduce timeout for faster testing
this.idleService.configure({
  idleTimeoutSeconds: 60,  // 1 minute instead of 15
  enableLogging: true      // See console logs
});

this.idleService.start();

Test Sequence:

  1. Fill out a form with some data
  2. Switch to another browser tab (or minimize window)
  3. Wait 60 seconds
  4. Switch back to the tab
  5. You should see the re-authentication dialog
  6. Click OK and authenticate
  7. Form should be restored with your data

Console Output (with logging enabled):

✅ Idle detection started
👁️ User went away (tab hidden)
⏱️ Starting idle timer
⏱️ Idle timeout detected (60 seconds elapsed)
💾 Saving all forms
🔐 Showing re-authentication dialog
✅ Re-authentication successful
⏱️ Idle timer reset
📝 Form restored from sessionStorage

Debugging

// 1. Enable logging
this.idleService.configure({ enableLogging: true });

// 2. Check service state
console.log('Service active:', this.idleService.isActive());
console.log('User present:', this.idleService.isUserPresent());
console.log('Time remaining:', this.idleService.getTimeRemaining(), 'seconds');

// 3. Check sessionStorage
console.log('SessionStorage keys:', Object.keys(sessionStorage));
console.log('Form keys:', Object.keys(sessionStorage).filter(k => k.startsWith('sessionpersist_form_')));

// 4. Subscribe to idle events
this.idleService.onIdleEvent$
  .pipe(filter(e => e.type === IdleEventType.TIMEOUT_EXCEEDED))
  .subscribe((event) => {
    console.log('🔔 Idle event fired!', event);
  });

// 5. Debug navigation tracking
const navTracker = inject(NavigationTrackerService);
console.log('Navigation debug:', navTracker.getDebugInfo());
// Output: { isAuthRedirect: false, navigationType: 'reload', shouldRestore: false }

Troubleshooting

Problem: Form Not Persisting

Symptom: Form data not saved or restored

Check:

  1. ✓ Directive imported: imports: [AutoPersistFormDirective]
  2. ✓ Form has [formGroup]: <form [formGroup]="myForm">
  3. ✓ Not disabled: Remove [autoPersist]="false" if present
  4. ✓ sessionStorage enabled in browser

Solution:

// Ensure directive is imported
@Component({
  imports: [CommonModule, ReactiveFormsModule, AutoPersistFormDirective]
})

// Ensure form has [formGroup]
<form [formGroup]="myForm"> <!-- Required! -->

// Enable persistence if disabled
<form [formGroup]="myForm" [autoPersist]="true">

Problem: Forms Restored on Manual Refresh

Symptom: Pressing F5 or Ctrl+R clears form data

This is expected behavior! The library intentionally clears forms on manual refresh to give users a fresh start.

Restoration Behavior:

| Action | Form Data | Why | |--------|-----------|-----| | F5 / Ctrl+R / Browser Refresh | ❌ Cleared | User explicitly requested fresh start | | Auth popup return | ✅ Restored | Preserve work during re-authentication | | Browser back/forward | ✅ Restored | Return to previous context | | SPA navigation | ✅ Restored | Switch between routes |

Debug Navigation Type:

import { NavigationTrackerService } from 'angular-msal-session-persistence';

constructor(private navTracker: NavigationTrackerService) {}

ngOnInit(): void {
  const debug = this.navTracker.getDebugInfo();
  console.log('Navigation type:', debug.navigationType);
  // 'reload' = manual refresh (forms cleared)
  // 'navigate' = SPA navigation (forms restored)
  // 'back_forward' = browser back/forward (forms restored)

  console.log('Should restore:', debug.shouldRestore);
  console.log('Is auth redirect:', debug.isAuthRedirect);
}

Problem: Idle Detection Not Working

Symptom: Timeout never triggers

Check:

  1. ✓ Service started: this.idleService.start()
  2. ✓ Subscribed to events: this.idleService.onIdleEvent$.pipe(filter(...)).subscribe(...)
  3. ✓ Timeout not too long: Test with 60 seconds
  4. ✓ User actually away: Must switch tabs or minimize window

Diagnosis:

// Check if service is started
console.log('Active:', this.idleService.isActive()); // Should be true

// Check if subscription exists
this.subscription = this.idleService.onIdleEvent$
  .pipe(filter(e => e.type === IdleEventType.TIMEOUT_EXCEEDED))
  .subscribe(() => {
    console.log('Event fired!'); // Should see this after timeout
  });

// Test with shorter timeout
this.idleService.configure({ idleTimeoutSeconds: 60 }); // 1 minute

// Check if user is away
console.log('User present:', this.idleService.isUserPresent()); // Should be false when away

Solution:

ngOnInit(): void {
  // 1. Configure
  this.idleService.configure({
    idleTimeoutSeconds: 60,
    enableLogging: true  // Enable logging
  });

  // 2. Subscribe
  this.subscription = this.idleService.onIdleEvent$
    .pipe(filter(e => e.type === IdleEventType.TIMEOUT_EXCEEDED))
    .subscribe((event) => {
      console.log('Idle timeout!', event);
      this.handleIdleTimeout();
    });

  // 3. Start
  this.idleService.start();

  // 4. Verify
  console.log('Service active:', this.idleService.isActive());
}

Problem: Popup Blocked

Symptom: Browser blocks authentication popup

Causes:

  • Popup not triggered by user interaction
  • Browser popup blocker enabled
  • Third-party extension blocking popups

Solutions:

// ✅ GOOD: Triggered by user click
<button (click)="login()">Login</button>

login(): void {
  this.authService.loginPopup(); // Works!
}

// ❌ BAD: Triggered automatically
ngOnInit(): void {
  this.authService.loginPopup(); // May be blocked!
}

Workarounds:

  1. Ensure dialog is triggered by user clicking OK/Re-authenticate button
  2. Check browser popup blocker settings
  3. Add your domain to allowed sites
  4. Use a visible button/link to trigger popup

Problem: Multiple Forms Collision Error

Symptom: Error message: Form key collision detected

Cause: Multiple forms on same page without unique keys

Solution:

<!-- Add unique keys to each form -->
<form [formGroup]="billingForm" [persistKey]="'checkout-billing'">...</form>
<form [formGroup]="shippingForm" [persistKey]="'checkout-shipping'">...</form>

<!-- Or use id/name attributes -->
<form [formGroup]="billingForm" id="billing">...</form>
<form [formGroup]="shippingForm" id="shipping">...</form>

Problem: Sleep/Wake Not Detected

Symptom: Computer sleep doesn't trigger re-authentication

Solution:

The library uses multiple detection methods that work together:

// Enable logging to see which events fire
this.idleService.configure({ enableLogging: true });

// Test sequence:
// 1. Close laptop lid
// 2. Wait 2+ minutes
// 3. Open laptop
// 4. Return to browser tab
// 5. Should see re-auth dialog

// Check browser support
console.log('Page Lifecycle API supported:', 'onfreeze' in document);

Form Type Support

Supported: Reactive Forms

Fully Supported - All features work with Reactive Forms ([formGroup])

// Reactive Forms
export class MyComponent {
  form = this.fb.group({
    firstName: [''],
    lastName: ['']
  });
}
<form [formGroup]="form">
  <input formControlName="firstName" />
  <input formControlName="lastName" />
</form>

Not Supported: Template-Driven Forms

Not Supported - Template-Driven Forms (ngForm) are not currently supported

<!-- This will NOT work -->
<form #myForm="ngForm">
  <input name="firstName" [(ngModel)]="user.firstName" />
  <input name="lastName" [(ngModel)]="user.lastName" />
</form>

Why Not Supported:

  • Uses completely different Angular API (NgForm vs FormGroup)
  • Would require separate directive implementation
  • Most modern Angular apps use Reactive Forms

Future Enhancement: Template-Driven support may be added in a future release if requested by users.


Design Philosophy

This library handles idle detection and form persistence. Your application continues to handle initial authentication (popup or redirect) - we only use popup for idle re-authentication to preserve page state.

Why This Approach?

  1. Separation of Concerns - Initial auth is your app's responsibility
  2. Page State Preservation - Popup for idle re-auth keeps forms in memory
  3. Flexibility - Works with apps using popup OR redirect for initial login
  4. User Experience - No disruptive full-page reloads during security checks

Example Scenarios:

// Scenario 1: Your app uses POPUP for initial login
this.msalService.loginPopup({ scopes: ['user.read'] });
// ✅ Package also uses popup for idle re-auth - consistent!

// Scenario 2: Your app uses REDIRECT for initial login
this.msalService.loginRedirect({ scopes: ['user.read'] });
// ✅ Package uses popup for idle re-auth - preserves state!

Contributing

Contributions are welcome! Please:

  1. Fork the repository
  2. Create feature branch: git checkout -b feature/my-feature
  3. Write tests for your changes
  4. Follow coding standards (ESLint + Prettier)
  5. Update documentation if needed
  6. Submit a pull request

Development Setup:

git clone https://github.com/yourusername/idlesession
cd idlesession
npm install
npm run build:sessionpersist

License

MIT License - see LICENSE file for details.

Support

Links


Version: 2.0.0 | License: MIT