AAE-39528 Session timeout features (#12008)

* feat: session timeout features
This commit is contained in:
Joshua Cain
2026-07-02 12:52:43 -04:00
committed by GitHub
parent 5a30baad99
commit 7f57b3936b
20 changed files with 1798 additions and 2 deletions
+44
View File
@@ -13,6 +13,50 @@ The authType parameter specifies the authentication method, with BASIC and OAUTH
"authType": "OAUTH"
}
```
## Session Timeout
ADF can track user activity and show a countdown dialog before logging out an idle authenticated session. Register the feature with `provideSessionTimeout` in your application providers:
```ts
import { ApplicationConfig } from '@angular/core';
import { provideSessionTimeout } from '@alfresco/adf-core';
export const appConfig: ApplicationConfig = {
providers: [
provideSessionTimeout({
enabled: true,
idleTimeoutMs: 30 * 60 * 1000,
dialogTimeoutMs: 60 * 1000
})
]
};
```
The same values can be configured in `app.config.json` with the `sessionTimeout` key:
```json
{
"sessionTimeout": {
"enabled": true,
"idleTimeoutMs": 1800000,
"dialogTimeoutMs": 60000
}
}
```
Registering `provideSessionTimeout` enables the feature: `enabled` defaults to `true`, so the countdown is active unless you set `enabled` to `false` in `app.config.json` or in the options passed to `provideSessionTimeout`. The default idle timeout is 30 minutes, and the default dialog timeout is 60 seconds. Values passed to `provideSessionTimeout` take precedence over values from `app.config.json`.
Applications can defer startup until another async condition is enabled:
```ts
provideSessionTimeout({
startWhen: () => featureService.isOn$('session-timeout-feature')
});
```
Whether the user clicks **Log out** in the countdown dialog or leaves it unanswered until the countdown elapses, the normal logout flow runs and the user is redirected to the configured IdP/login page. The same logout is broadcast to other tabs so every session ends together.
# OAuth2 Configuration
OAuth2 is a protocol that allows the application to authorize operations without exposing user credentials. The configuration includes several parameters essential for setting up OAuth2 authentication.
@@ -80,6 +80,7 @@ describe('OidcAuthenticationService', () => {
});
service = TestBed.inject(OidcAuthenticationService);
oauthService = TestBed.inject(OAuthService);
mockOAuthService.logOut.calls.reset();
});
it('should be created', () => {
@@ -165,6 +166,14 @@ describe('OidcAuthenticationService', () => {
});
});
describe('reset', () => {
it('should clear local OAuth state without redirecting to the IdP', () => {
service.reset();
expect(oauthService.logOut as jasmine.Spy).toHaveBeenCalledOnceWith(true);
});
});
describe('loggedIn', () => {
it('should return true if has valid tokens', () => {
mockOAuthService.hasValidAccessToken.and.returnValue(true);
@@ -131,8 +131,7 @@ export class OidcAuthenticationService extends BaseAuthenticationService {
}
reset(): void {
const config = this.authConfig.loadAppConfig();
this.auth.updateIDPConfiguration(config);
this.oauthService.logOut(true);
}
isPublicUrl(): boolean {
+1
View File
@@ -49,4 +49,5 @@ export * from './models/identity-role.model';
export * from './models/user-access.model';
export * from './models/application-access.model';
export * from './session-timeout/public-api';
export * from './oidc/public-api';
@@ -0,0 +1,112 @@
/*!
* @license
* Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { DOCUMENT } from '@angular/common';
import { NgZone } from '@angular/core';
import { fakeAsync, TestBed, tick } from '@angular/core/testing';
import { ACTIVITY_THROTTLE_MS, IdleActivityTracker } from './idle-activity-tracker';
describe('IdleActivityTracker', () => {
let tracker: IdleActivityTracker;
let doc: Document;
beforeEach(() => {
TestBed.configureTestingModule({ providers: [IdleActivityTracker] });
tracker = TestBed.inject(IdleActivityTracker);
doc = TestBed.inject(DOCUMENT);
});
afterEach(() => tracker.stop());
it('emits on activity$ when a registered DOM event fires after start()', () => {
const spy = jasmine.createSpy('activity');
tracker.activity$.subscribe(spy);
tracker.start();
doc.dispatchEvent(new Event('click'));
expect(spy).toHaveBeenCalledTimes(1);
});
it('throttles a burst of activity events within the throttle window', fakeAsync(() => {
const spy = jasmine.createSpy('activity');
tracker.activity$.subscribe(spy);
tracker.start();
// Leading edge emits immediately, the rest of the burst is throttled.
for (let i = 0; i < 10; i++) {
doc.dispatchEvent(new Event('mousemove'));
}
expect(spy).toHaveBeenCalledTimes(1);
// After the throttle window, activity emits again.
tick(ACTIVITY_THROTTLE_MS);
doc.dispatchEvent(new Event('mousemove'));
expect(spy.calls.count()).toBeGreaterThan(1);
tick(ACTIVITY_THROTTLE_MS);
}));
it('does not emit after stop()', () => {
const spy = jasmine.createSpy('activity');
tracker.activity$.subscribe(spy);
tracker.start();
tracker.stop();
doc.dispatchEvent(new Event('click'));
expect(spy).not.toHaveBeenCalled();
});
it('registers listeners only once across repeated start() calls', () => {
const spy = jasmine.createSpy('activity');
tracker.activity$.subscribe(spy);
tracker.start();
tracker.start();
doc.dispatchEvent(new Event('click'));
expect(spy).toHaveBeenCalledTimes(1);
});
it('is a no-op when stop() is called without a prior start()', () => {
const spy = jasmine.createSpy('activity');
tracker.activity$.subscribe(spy);
expect(() => tracker.stop()).not.toThrow();
doc.dispatchEvent(new Event('click'));
expect(spy).not.toHaveBeenCalled();
});
it('emits the current visibility state on visibilitychange', () => {
const spy = jasmine.createSpy('visibility');
tracker.visibilityChange$.subscribe(spy);
tracker.start();
doc.dispatchEvent(new Event('visibilitychange'));
expect(spy).toHaveBeenCalledWith(doc.visibilityState);
});
it('registers listeners outside the Angular zone', () => {
const ngZone = TestBed.inject(NgZone);
const runOutside = spyOn(ngZone, 'runOutsideAngular').and.callThrough();
tracker.start();
expect(runOutside).toHaveBeenCalled();
});
});
@@ -0,0 +1,74 @@
/*!
* @license
* Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { DOCUMENT } from '@angular/common';
import { Injectable, NgZone, OnDestroy, inject } from '@angular/core';
import { Observable, Subject } from 'rxjs';
import { throttleTime } from 'rxjs/operators';
export const ACTIVITY_EVENTS = ['click', 'keydown', 'mousedown', 'mousemove', 'pointerdown', 'scroll', 'touchstart', 'wheel'] as const;
/** High-frequency activity events (e.g. mousemove, scroll) are throttled to avoid rescheduling the idle timer on every DOM event. */
export const ACTIVITY_THROTTLE_MS = 1000;
@Injectable()
export class IdleActivityTracker implements OnDestroy {
private readonly document = inject(DOCUMENT);
private readonly ngZone = inject(NgZone);
private readonly activitySubject = new Subject<void>();
private readonly visibilitySubject = new Subject<DocumentVisibilityState>();
private isRegistered = false;
readonly activity$: Observable<void> = this.activitySubject
.asObservable()
.pipe(throttleTime(ACTIVITY_THROTTLE_MS, undefined, { leading: true, trailing: true }));
readonly visibilityChange$: Observable<DocumentVisibilityState> = this.visibilitySubject.asObservable();
start(): void {
if (this.isRegistered) {
return;
}
this.ngZone.runOutsideAngular(() => {
ACTIVITY_EVENTS.forEach((eventName) => this.document.addEventListener(eventName, this.handleActivity, { passive: true }));
this.document.addEventListener('visibilitychange', this.handleVisibilityChange);
});
this.isRegistered = true;
}
stop(): void {
if (!this.isRegistered) {
return;
}
ACTIVITY_EVENTS.forEach((eventName) => this.document.removeEventListener(eventName, this.handleActivity));
this.document.removeEventListener('visibilitychange', this.handleVisibilityChange);
this.isRegistered = false;
}
ngOnDestroy(): void {
this.stop();
}
private readonly handleActivity = (): void => {
this.activitySubject.next();
};
private readonly handleVisibilityChange = (): void => {
this.visibilitySubject.next(this.document.visibilityState);
};
}
@@ -0,0 +1,70 @@
/*!
* @license
* Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ApplicationInitStatus } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { Subject } from 'rxjs';
import { provideSessionTimeout } from './provide-session-timeout';
import { SessionTimeoutService } from './session-timeout.service';
import { AppConfigService } from '../../app-config/app-config.service';
import { AuthenticationService } from '../services/authentication.service';
describe('provideSessionTimeout', () => {
it('registers SessionTimeoutService and runs its start() during app init', async () => {
const startSpy = spyOn(SessionTimeoutService.prototype, 'start');
TestBed.configureTestingModule({
providers: [
provideSessionTimeout(),
{ provide: AppConfigService, useValue: { get: () => ({}), isLoaded: true, onLoad: new Subject() } },
{
provide: AuthenticationService,
useValue: { isLoggedIn: () => false, logout: () => {}, onLogin: new Subject(), onLogout: new Subject() }
}
]
});
await TestBed.inject(ApplicationInitStatus).donePromise;
expect(startSpy).toHaveBeenCalled();
});
it('waits for startWhen to emit true before starting SessionTimeoutService', async () => {
const startWhen$ = new Subject<boolean>();
const startSpy = spyOn(SessionTimeoutService.prototype, 'start');
TestBed.configureTestingModule({
providers: [
provideSessionTimeout({ startWhen: () => startWhen$ }),
{ provide: AppConfigService, useValue: { get: () => ({}), isLoaded: true, onLoad: new Subject() } },
{
provide: AuthenticationService,
useValue: { isLoggedIn: () => false, logout: () => {}, onLogin: new Subject(), onLogout: new Subject() }
}
]
});
await TestBed.inject(ApplicationInitStatus).donePromise;
expect(startSpy).not.toHaveBeenCalled();
startWhen$.next(false);
expect(startSpy).not.toHaveBeenCalled();
startWhen$.next(true);
expect(startSpy).toHaveBeenCalledTimes(1);
startWhen$.next(true);
expect(startSpy).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,53 @@
/*!
* @license
* Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { EnvironmentProviders, inject, makeEnvironmentProviders, provideAppInitializer } from '@angular/core';
import { SESSION_TIMEOUT_OPTIONS, SessionTimeoutOptions } from './session-timeout.config';
import { IdleActivityTracker } from './idle-activity-tracker';
import { SessionTimeoutSyncChannel } from './session-timeout-sync-channel';
import { SessionTimeoutService } from './session-timeout.service';
import { filter, take } from 'rxjs/operators';
/**
* Provides the session timeout feature: idle tracking, the countdown dialog and cross-tab sync.
*
* When the countdown dialog is not answered (or the user clicks "Log out"), the normal logout flow
* runs and the user is redirected to the configured IdP/login page.
*
* @param options - Optional overrides that take precedence over the `sessionTimeout` app config block
* @returns Environment providers that register the service and start it during app initialization
*/
export function provideSessionTimeout(options?: SessionTimeoutOptions): EnvironmentProviders {
return makeEnvironmentProviders([
{ provide: SESSION_TIMEOUT_OPTIONS, useValue: options ?? {} },
IdleActivityTracker,
SessionTimeoutSyncChannel,
SessionTimeoutService,
provideAppInitializer(() => {
const sessionTimeoutService = inject(SessionTimeoutService);
const sessionTimeoutOptions = inject(SESSION_TIMEOUT_OPTIONS);
const startWhen$ = sessionTimeoutOptions.startWhen?.();
if (!startWhen$) {
sessionTimeoutService.start();
return;
}
startWhen$.pipe(filter(Boolean), take(1)).subscribe(() => sessionTimeoutService.start());
})
]);
}
@@ -0,0 +1,23 @@
/*!
* @license
* Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export * from './session-timeout.config';
export * from './idle-activity-tracker';
export * from './session-timeout-sync-channel';
export * from './session-timeout-dialog.component';
export * from './session-timeout.service';
export * from './provide-session-timeout';
@@ -0,0 +1,14 @@
<h2 mat-dialog-title>{{ 'SESSION_TIMEOUT.TITLE' | translate }}</h2>
<mat-dialog-content aria-live="polite">
{{ 'SESSION_TIMEOUT.MESSAGE' | translate: { seconds: remainingSeconds } }}
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-button type="button" data-automation-id="session-timeout-dialog__logout-button" (click)="logout()">
{{ 'SESSION_TIMEOUT.LOG_OUT' | translate }}
</button>
<button mat-flat-button color="primary" type="button" data-automation-id="session-timeout-dialog__continue-button" (click)="continueWorking()">
{{ 'SESSION_TIMEOUT.CONTINUE_WORKING' | translate }}
</button>
</mat-dialog-actions>
@@ -0,0 +1,4 @@
.adf-session-timeout-backdrop {
background-color: rgba(0, 0, 0, 0.45);
backdrop-filter: blur(16px);
}
@@ -0,0 +1,64 @@
/*!
* @license
* Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { fakeAsync, TestBed, tick } from '@angular/core/testing';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { TranslateModule } from '@ngx-translate/core';
import { SessionTimeoutDialogComponent } from './session-timeout-dialog.component';
describe('SessionTimeoutDialogComponent', () => {
const dialogRef = { close: jasmine.createSpy('close') };
beforeEach(() => {
dialogRef.close.calls.reset();
TestBed.configureTestingModule({
imports: [SessionTimeoutDialogComponent, NoopAnimationsModule, TranslateModule.forRoot()],
providers: [
{ provide: MatDialogRef, useValue: dialogRef },
{ provide: MAT_DIALOG_DATA, useValue: { dialogTimeoutMs: 3000 } }
]
});
});
it('initializes remainingSeconds from dialogTimeoutMs', () => {
const fixture = TestBed.createComponent(SessionTimeoutDialogComponent);
expect(fixture.componentInstance.remainingSeconds).toBe(3);
});
it('counts down each second', fakeAsync(() => {
const fixture = TestBed.createComponent(SessionTimeoutDialogComponent);
fixture.detectChanges();
tick(1000);
expect(fixture.componentInstance.remainingSeconds).toBe(2);
tick(1000);
expect(fixture.componentInstance.remainingSeconds).toBe(1);
fixture.destroy();
}));
it('closes with true on continueWorking()', () => {
const fixture = TestBed.createComponent(SessionTimeoutDialogComponent);
fixture.componentInstance.continueWorking();
expect(dialogRef.close).toHaveBeenCalledWith(true);
});
it('closes with false on logout()', () => {
const fixture = TestBed.createComponent(SessionTimeoutDialogComponent);
fixture.componentInstance.logout();
expect(dialogRef.close).toHaveBeenCalledWith(false);
});
});
@@ -0,0 +1,67 @@
/*!
* @license
* Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnDestroy, ViewEncapsulation, inject } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
import { TranslatePipe } from '@ngx-translate/core';
export const SESSION_TIMEOUT_BACKDROP_CLASS = 'adf-session-timeout-backdrop';
export interface SessionTimeoutDialogData {
dialogTimeoutMs: number;
}
@Component({
selector: 'adf-session-timeout-dialog',
standalone: true,
imports: [MatButtonModule, MatDialogModule, TranslatePipe],
templateUrl: './session-timeout-dialog.component.html',
styleUrl: './session-timeout-dialog.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None
})
export class SessionTimeoutDialogComponent implements OnDestroy {
private readonly dialogRef = inject<MatDialogRef<SessionTimeoutDialogComponent, boolean>>(MatDialogRef);
private readonly data = inject<SessionTimeoutDialogData>(MAT_DIALOG_DATA);
private readonly changeDetectorRef = inject(ChangeDetectorRef);
private readonly timeoutEndTime = Date.now() + this.data.dialogTimeoutMs;
private readonly countdownIntervalId = setInterval(() => this.updateRemainingSeconds(), 1000);
remainingSeconds = this.getRemainingSeconds();
ngOnDestroy(): void {
clearInterval(this.countdownIntervalId);
}
continueWorking(): void {
this.dialogRef.close(true);
}
logout(): void {
this.dialogRef.close(false);
}
private updateRemainingSeconds(): void {
this.remainingSeconds = this.getRemainingSeconds();
this.changeDetectorRef.markForCheck();
}
private getRemainingSeconds(): number {
return Math.max(Math.ceil((this.timeoutEndTime - Date.now()) / 1000), 0);
}
}
@@ -0,0 +1,119 @@
/*!
* @license
* Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { DOCUMENT } from '@angular/common';
import { TestBed } from '@angular/core/testing';
import { SessionTimeoutSyncChannel } from './session-timeout-sync-channel';
class MockBroadcastChannel {
static instances: MockBroadcastChannel[] = [];
postMessage = jasmine.createSpy('postMessage').and.callFake((message: unknown) => {
MockBroadcastChannel.instances
.filter((instance) => instance !== this && instance.name === this.name)
.forEach((instance) => instance.dispatch(message));
});
close = jasmine.createSpy('close');
private readonly listeners = new Set<(event: MessageEvent<unknown>) => void>();
constructor(public name: string) {
MockBroadcastChannel.instances.push(this);
}
addEventListener(type: string, listener: (event: MessageEvent<unknown>) => void): void {
if (type === 'message') {
this.listeners.add(listener);
}
}
removeEventListener(_type: string, listener: (event: MessageEvent<unknown>) => void): void {
this.listeners.delete(listener);
}
dispatch(data: unknown): void {
this.listeners.forEach((listener) => listener({ data } as MessageEvent<unknown>));
}
}
describe('SessionTimeoutSyncChannel', () => {
let doc: Document;
let original: typeof BroadcastChannel | undefined;
beforeEach(() => {
MockBroadcastChannel.instances = [];
TestBed.configureTestingModule({ providers: [SessionTimeoutSyncChannel] });
doc = TestBed.inject(DOCUMENT);
original = (doc.defaultView as any).BroadcastChannel;
(doc.defaultView as any).BroadcastChannel = MockBroadcastChannel;
});
afterEach(() => {
(doc.defaultView as any).BroadcastChannel = original;
});
it('posts a well-formed event with type, sourceTabId and createdAt', () => {
const channel = TestBed.inject(SessionTimeoutSyncChannel);
channel.open();
channel.post('activity');
const instance = MockBroadcastChannel.instances[0];
expect(instance.postMessage).toHaveBeenCalledTimes(1);
const payload = instance.postMessage.calls.mostRecent().args[0];
expect(payload.type).toBe('activity');
expect(typeof payload.sourceTabId).toBe('string');
expect(typeof payload.createdAt).toBe('number');
});
it('ignores messages originating from its own tab', () => {
const channel = TestBed.inject(SessionTimeoutSyncChannel);
const spy = jasmine.createSpy('messages');
channel.messages$.subscribe(spy);
channel.open();
const instance = MockBroadcastChannel.instances[0];
channel.post('timeout');
instance.dispatch(instance.postMessage.calls.mostRecent().args[0]);
expect(spy).not.toHaveBeenCalled();
});
it('emits validated messages from other tabs', () => {
const channel = TestBed.inject(SessionTimeoutSyncChannel);
const spy = jasmine.createSpy('messages');
channel.messages$.subscribe(spy);
channel.open();
const instance = MockBroadcastChannel.instances[0];
instance.dispatch({ type: 'logout', sourceTabId: 'other-tab', createdAt: 123 });
expect(spy).toHaveBeenCalledWith({ type: 'logout', sourceTabId: 'other-tab', createdAt: 123 });
});
it('drops malformed messages', () => {
const channel = TestBed.inject(SessionTimeoutSyncChannel);
const spy = jasmine.createSpy('messages');
channel.messages$.subscribe(spy);
channel.open();
const instance = MockBroadcastChannel.instances[0];
instance.dispatch({ type: 'nope' });
instance.dispatch(null);
expect(spy).not.toHaveBeenCalled();
});
it('post() does not throw when channel is unavailable', () => {
(doc.defaultView as any).BroadcastChannel = undefined;
const channel = TestBed.inject(SessionTimeoutSyncChannel);
channel.open();
expect(() => channel.post('activity')).not.toThrow();
});
});
@@ -0,0 +1,120 @@
/*!
* @license
* Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { DOCUMENT } from '@angular/common';
import { Injectable, OnDestroy, inject } from '@angular/core';
import { Observable, Subject } from 'rxjs';
const SESSION_TIMEOUT_CHANNEL_NAME = 'adf-session-timeout';
export const SESSION_TIMEOUT_SYNC_EVENT_TYPES = ['activity', 'timeout', 'continue', 'logout'] as const;
export type SessionTimeoutSyncEventType = (typeof SESSION_TIMEOUT_SYNC_EVENT_TYPES)[number];
export interface SessionTimeoutSyncEvent {
type: SessionTimeoutSyncEventType;
sourceTabId: string;
createdAt: number;
}
@Injectable()
export class SessionTimeoutSyncChannel implements OnDestroy {
private readonly document = inject(DOCUMENT);
private readonly window = this.document.defaultView;
private readonly tabId = this.createTabId();
private readonly messageSubject = new Subject<SessionTimeoutSyncEvent>();
private channel: BroadcastChannel | undefined;
readonly messages$: Observable<SessionTimeoutSyncEvent> = this.messageSubject.asObservable();
open(): void {
if (this.channel || !this.window?.BroadcastChannel) {
return;
}
this.channel = new this.window.BroadcastChannel(SESSION_TIMEOUT_CHANNEL_NAME);
this.channel.addEventListener('message', this.handleMessage);
}
close(): void {
if (!this.channel) {
return;
}
this.channel.removeEventListener('message', this.handleMessage);
this.channel.close();
this.channel = undefined;
}
post(type: SessionTimeoutSyncEventType): void {
try {
this.channel?.postMessage({
type,
sourceTabId: this.tabId,
createdAt: Date.now()
} satisfies SessionTimeoutSyncEvent);
} catch {
/* empty */
}
}
ngOnDestroy(): void {
this.close();
}
private readonly handleMessage = (event: MessageEvent<unknown>): void => {
const syncEvent = this.parse(event.data);
if (!syncEvent || syncEvent.sourceTabId === this.tabId) {
return;
}
this.messageSubject.next(syncEvent);
};
private parse(value: unknown): SessionTimeoutSyncEvent | undefined {
if (typeof value !== 'object' || value === null) {
return undefined;
}
const syncEvent = value as Partial<SessionTimeoutSyncEvent>;
if (
typeof syncEvent.type === 'string' &&
(SESSION_TIMEOUT_SYNC_EVENT_TYPES as readonly string[]).includes(syncEvent.type) &&
typeof syncEvent.sourceTabId === 'string' &&
typeof syncEvent.createdAt === 'number'
) {
return syncEvent as SessionTimeoutSyncEvent;
}
return undefined;
}
private createTabId(): string {
const crypto = this.window?.crypto;
if (crypto?.randomUUID) {
return crypto.randomUUID();
}
if (crypto?.getRandomValues) {
const buffer = new Uint32Array(4);
crypto.getRandomValues(buffer);
return Array.from(buffer, (value) => value.toString(16)).join('-');
}
return `${Date.now()}-${this.window?.performance?.now?.() ?? 0}`;
}
}
@@ -0,0 +1,47 @@
/*!
* @license
* Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { normalizeSessionTimeoutOptions, DEFAULT_SESSION_TIMEOUT_OPTIONS } from './session-timeout.config';
describe('normalizeSessionTimeoutOptions', () => {
it('coerces string "true" to boolean enabled', () => {
expect(normalizeSessionTimeoutOptions({ enabled: 'true' }).enabled).toBe(true);
});
it('preserves boolean enabled values', () => {
expect(normalizeSessionTimeoutOptions({ enabled: true }).enabled).toBe(true);
expect(normalizeSessionTimeoutOptions({ enabled: false }).enabled).toBe(false);
});
it('treats non-"true" strings and missing as disabled', () => {
expect(normalizeSessionTimeoutOptions({ enabled: 'false' }).enabled).toBe(false);
expect(normalizeSessionTimeoutOptions({}).enabled).toBe(false);
});
it('parses numeric strings for timeouts', () => {
const result = normalizeSessionTimeoutOptions({ idleTimeoutMs: '1000', dialogTimeoutMs: '2000' });
expect(result.idleTimeoutMs).toBe(1000);
expect(result.dialogTimeoutMs).toBe(2000);
});
it('falls back to defaults for non-positive or invalid numbers', () => {
const result = normalizeSessionTimeoutOptions({ idleTimeoutMs: 0, dialogTimeoutMs: 'abc' });
expect(result.enabled).toBe(DEFAULT_SESSION_TIMEOUT_OPTIONS.enabled);
expect(result.idleTimeoutMs).toBe(DEFAULT_SESSION_TIMEOUT_OPTIONS.idleTimeoutMs);
expect(result.dialogTimeoutMs).toBe(DEFAULT_SESSION_TIMEOUT_OPTIONS.dialogTimeoutMs);
});
});
@@ -0,0 +1,63 @@
/*!
* @license
* Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { InjectionToken } from '@angular/core';
import { Observable } from 'rxjs';
export const SESSION_TIMEOUT_CONFIG_KEY = 'sessionTimeout';
export interface SessionTimeoutOptions {
enabled?: boolean | string;
idleTimeoutMs?: number | string;
dialogTimeoutMs?: number | string;
startWhen?: () => Observable<boolean>;
}
export interface NormalizedSessionTimeoutOptions {
enabled: boolean;
idleTimeoutMs: number;
dialogTimeoutMs: number;
}
export const DEFAULT_SESSION_TIMEOUT_OPTIONS: NormalizedSessionTimeoutOptions = {
enabled: false,
idleTimeoutMs: 30 * 60 * 1000,
dialogTimeoutMs: 60 * 1000
};
export const SESSION_TIMEOUT_OPTIONS = new InjectionToken<SessionTimeoutOptions>('SESSION_TIMEOUT_OPTIONS');
const toBoolean = (value: boolean | string | undefined): boolean => value === true || value === 'true';
const toPositiveNumber = (value: number | string | undefined, fallback: number): number => {
const parsed = Number(value);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
};
/**
* Coerces raw session timeout options (which may arrive as strings from app config) into validated values.
*
* @param merged - Raw options merged from defaults, app config and provider input
* @returns Normalized options with a boolean `enabled` flag and positive numeric timeouts
*/
export function normalizeSessionTimeoutOptions(merged: SessionTimeoutOptions): NormalizedSessionTimeoutOptions {
return {
enabled: merged.enabled === undefined ? DEFAULT_SESSION_TIMEOUT_OPTIONS.enabled : toBoolean(merged.enabled),
idleTimeoutMs: toPositiveNumber(merged.idleTimeoutMs, DEFAULT_SESSION_TIMEOUT_OPTIONS.idleTimeoutMs),
dialogTimeoutMs: toPositiveNumber(merged.dialogTimeoutMs, DEFAULT_SESSION_TIMEOUT_OPTIONS.dialogTimeoutMs)
};
}
@@ -0,0 +1,537 @@
/*!
* @license
* Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { TestBed, fakeAsync, flushMicrotasks, tick } from '@angular/core/testing';
import { Observable, Subject, defer } from 'rxjs';
import { MatDialog } from '@angular/material/dialog';
import { AppConfigService } from '../../app-config/app-config.service';
import { AuthenticationService } from '../services/authentication.service';
import { SessionTimeoutService } from './session-timeout.service';
import { SessionTimeoutOptions, SESSION_TIMEOUT_OPTIONS } from './session-timeout.config';
import { IdleActivityTracker } from './idle-activity-tracker';
import { SessionTimeoutSyncChannel, SessionTimeoutSyncEvent } from './session-timeout-sync-channel';
import { SESSION_TIMEOUT_BACKDROP_CLASS } from './session-timeout-dialog.component';
describe('SessionTimeoutService', () => {
let authService: {
isLoggedIn: jasmine.Spy<() => boolean>;
logout: jasmine.Spy<() => Observable<void>>;
reset: jasmine.Spy<() => void>;
onLogin: Subject<unknown>;
onLogout: Subject<unknown>;
};
let logoutSideEffect: jasmine.Spy;
let appConfigService: {
get: jasmine.Spy<(key: string, fallback: SessionTimeoutOptions) => SessionTimeoutOptions>;
isLoaded: boolean;
onLoad: Subject<unknown>;
};
let dialog: {
open: jasmine.Spy;
};
let tracker: {
start: jasmine.Spy;
stop: jasmine.Spy;
activity$: Subject<void>;
visibilityChange$: Subject<DocumentVisibilityState>;
};
let syncChannel: {
open: jasmine.Spy;
close: jasmine.Spy;
post: jasmine.Spy;
messages$: Subject<SessionTimeoutSyncEvent>;
};
let dialogClosed$: Subject<boolean | undefined>;
let isLoggedIn: boolean;
const configureTestingModule = (sessionTimeoutOptions?: SessionTimeoutOptions, providerOptions?: SessionTimeoutOptions) => {
isLoggedIn = true;
logoutSideEffect = jasmine.createSpy('logoutSideEffect');
authService = {
isLoggedIn: jasmine.createSpy('isLoggedIn').and.callFake(() => isLoggedIn),
logout: jasmine.createSpy('logout').and.callFake(() =>
defer(() => {
logoutSideEffect();
return new Observable<void>((subscriber) => subscriber.complete());
})
),
reset: jasmine.createSpy('reset'),
onLogin: new Subject<unknown>(),
onLogout: new Subject<unknown>()
};
appConfigService = {
get: jasmine.createSpy('get').and.callFake((_key: string, fallback: SessionTimeoutOptions) => sessionTimeoutOptions ?? fallback),
isLoaded: true,
onLoad: new Subject<unknown>()
};
dialogClosed$ = new Subject<boolean | undefined>();
dialog = {
open: jasmine.createSpy('open').and.returnValue({
afterClosed: () => dialogClosed$,
close: (value?: boolean) => {
dialogClosed$.next(value);
dialogClosed$.complete();
}
})
};
tracker = {
start: jasmine.createSpy('start'),
stop: jasmine.createSpy('stop'),
activity$: new Subject<void>(),
visibilityChange$: new Subject<DocumentVisibilityState>()
};
syncChannel = {
open: jasmine.createSpy('open'),
close: jasmine.createSpy('close'),
post: jasmine.createSpy('post'),
messages$: new Subject<SessionTimeoutSyncEvent>()
};
TestBed.configureTestingModule({
providers: [
SessionTimeoutService,
{ provide: SESSION_TIMEOUT_OPTIONS, useValue: providerOptions ?? {} },
{ provide: AuthenticationService, useValue: authService },
{ provide: AppConfigService, useValue: appConfigService },
{ provide: MatDialog, useValue: dialog },
{ provide: IdleActivityTracker, useValue: tracker },
{ provide: SessionTimeoutSyncChannel, useValue: syncChannel }
]
});
};
const startService = ({ emitLoginEvent = true } = {}) => {
const service = TestBed.inject(SessionTimeoutService);
service.start();
if (emitLoginEvent) {
authService.onLogin.next(undefined);
}
return service;
};
afterEach(() => {
TestBed.resetTestingModule();
});
it('should not activate when session timeout is disabled', fakeAsync(() => {
configureTestingModule({ enabled: false, idleTimeoutMs: 1000 });
startService();
tick(1000);
expect(tracker.start).not.toHaveBeenCalled();
expect(syncChannel.open).not.toHaveBeenCalled();
expect(dialog.open).not.toHaveBeenCalled();
expect(authService.logout).not.toHaveBeenCalled();
}));
it('should not activate by default when enabled is not configured', fakeAsync(() => {
configureTestingModule({ idleTimeoutMs: 1000 });
startService();
expect(tracker.start).not.toHaveBeenCalled();
expect(syncChannel.open).not.toHaveBeenCalled();
}));
it('should wait for app config to load before starting', fakeAsync(() => {
configureTestingModule({ enabled: true, idleTimeoutMs: 1000 });
appConfigService.isLoaded = false;
startService({ emitLoginEvent: false });
tick(1000);
expect(tracker.start).not.toHaveBeenCalled();
appConfigService.isLoaded = true;
appConfigService.onLoad.next({});
flushMicrotasks();
authService.onLogin.next(undefined);
tick(1000);
expect(tracker.start).toHaveBeenCalledTimes(1);
expect(dialog.open).toHaveBeenCalledTimes(1);
}));
it('should start tracker and open sync channel on login when logged in', fakeAsync(() => {
configureTestingModule({ enabled: true, idleTimeoutMs: 1000 });
startService();
expect(tracker.start).toHaveBeenCalledTimes(1);
expect(syncChannel.open).toHaveBeenCalledTimes(1);
}));
it('should clear session state when login event fires but user is not logged in', fakeAsync(() => {
configureTestingModule({ enabled: true, idleTimeoutMs: 1000 });
isLoggedIn = false;
startService();
tick(1000);
expect(tracker.start).not.toHaveBeenCalled();
expect(dialog.open).not.toHaveBeenCalled();
}));
it('should arm immediately on start when a session is already authenticated', fakeAsync(() => {
configureTestingModule({ enabled: true, idleTimeoutMs: 1000 });
// No onLogin event is emitted: the session predates this service.
startService({ emitLoginEvent: false });
expect(tracker.start).toHaveBeenCalledTimes(1);
expect(syncChannel.open).toHaveBeenCalledTimes(1);
tick(1000);
expect(dialog.open).toHaveBeenCalledTimes(1);
}));
it('should not leak handler subscriptions across logout and login cycles', fakeAsync(() => {
configureTestingModule({ enabled: true, idleTimeoutMs: 1000 });
startService();
expect(tracker.activity$.observers.length).toBe(1);
expect(syncChannel.messages$.observers.length).toBe(1);
authService.onLogout.next({});
flushMicrotasks();
// Logout must tear down the session-scoped subscriptions.
expect(tracker.activity$.observers.length).toBe(0);
expect(syncChannel.messages$.observers.length).toBe(0);
authService.onLogin.next(undefined);
// Re-login arms exactly one fresh set, never two.
expect(tracker.activity$.observers.length).toBe(1);
expect(syncChannel.messages$.observers.length).toBe(1);
}));
it('should not re-arm or duplicate subscriptions when redundant login events fire', fakeAsync(() => {
configureTestingModule({ enabled: true, idleTimeoutMs: 1000 });
startService();
authService.onLogin.next(undefined);
authService.onLogin.next(undefined);
expect(tracker.start).toHaveBeenCalledTimes(1);
expect(syncChannel.open).toHaveBeenCalledTimes(1);
expect(tracker.activity$.observers.length).toBe(1);
expect(syncChannel.messages$.observers.length).toBe(1);
}));
it('should open dialog after idle timeout with no activity', fakeAsync(() => {
configureTestingModule({ enabled: true, idleTimeoutMs: 1000 });
startService();
tick(1000);
expect(dialog.open).toHaveBeenCalledTimes(1);
expect(dialog.open).toHaveBeenCalledWith(
jasmine.any(Function),
jasmine.objectContaining({
data: { dialogTimeoutMs: 60000 },
backdropClass: SESSION_TIMEOUT_BACKDROP_CLASS,
disableClose: true,
width: '420px'
})
);
expect(authService.logout).not.toHaveBeenCalled();
}));
it('should reschedule timeout when activity occurs before idle timeout', fakeAsync(() => {
configureTestingModule({ enabled: true, idleTimeoutMs: 1000 });
startService();
tick(750);
tracker.activity$.next();
tick(750);
expect(dialog.open).not.toHaveBeenCalled();
tick(250);
expect(dialog.open).toHaveBeenCalledTimes(1);
}));
it('should ignore activity while dialog is open', fakeAsync(() => {
configureTestingModule({ enabled: true, idleTimeoutMs: 1000 });
startService();
tick(1000);
expect(dialog.open).toHaveBeenCalledTimes(1);
tracker.activity$.next();
tick(1000);
expect(dialog.open).toHaveBeenCalledTimes(1);
}));
it('should continue working when dialog is confirmed', fakeAsync(() => {
configureTestingModule({ enabled: true, idleTimeoutMs: 1000 });
startService();
tick(1000);
dialogClosed$.next(true);
flushMicrotasks();
expect(authService.logout).not.toHaveBeenCalled();
expect(syncChannel.post).toHaveBeenCalledWith('continue');
tick(999);
expect(dialog.open).toHaveBeenCalledTimes(1);
tick(1);
expect(dialog.open).toHaveBeenCalledTimes(2);
}));
it('should run the full logout flow when the dialog auto-closes on timeout', fakeAsync(() => {
configureTestingModule({ enabled: true, idleTimeoutMs: 1000, dialogTimeoutMs: 500 });
startService();
tick(1000);
tick(500);
flushMicrotasks();
// An unanswered (timed-out) dialog logs the user out and redirects, same as the explicit "Log out" button.
expect(authService.logout).toHaveBeenCalledTimes(1);
expect(logoutSideEffect).toHaveBeenCalledTimes(1);
expect(authService.reset).not.toHaveBeenCalled();
// Other tabs are told to log out too.
expect(syncChannel.post).toHaveBeenCalledWith('logout');
}));
it('should logout (not redirect) when the user explicitly chooses to log out', fakeAsync(() => {
configureTestingModule({ enabled: true, idleTimeoutMs: 1000 });
startService();
tick(1000);
dialogClosed$.next(false);
flushMicrotasks();
expect(authService.logout).toHaveBeenCalledTimes(1);
expect(logoutSideEffect).toHaveBeenCalledTimes(1);
}));
it('should logout once when dialog is rejected (idempotent)', fakeAsync(() => {
configureTestingModule({ enabled: true, idleTimeoutMs: 1000 });
startService();
tick(1000);
dialogClosed$.next(false);
flushMicrotasks();
expect(authService.logout).toHaveBeenCalledTimes(1);
expect(syncChannel.post).toHaveBeenCalledWith('logout');
// Try to logout again
dialogClosed$.next(false);
flushMicrotasks();
expect(authService.logout).toHaveBeenCalledTimes(1);
}));
it('should throttle activity sync posts to one per 1000ms', fakeAsync(() => {
configureTestingModule({ enabled: true, idleTimeoutMs: 5000 });
startService();
tracker.activity$.next();
tracker.activity$.next();
tracker.activity$.next();
expect(syncChannel.post).toHaveBeenCalledWith('activity');
expect(syncChannel.post).toHaveBeenCalledTimes(1);
tick(999);
tracker.activity$.next();
expect(syncChannel.post).toHaveBeenCalledTimes(1);
tick(1);
tracker.activity$.next();
expect(syncChannel.post).toHaveBeenCalledTimes(2);
}));
it('should continue session when inbound activity message received', fakeAsync(() => {
configureTestingModule({ enabled: true, idleTimeoutMs: 1000 });
startService();
tick(750);
syncChannel.messages$.next({
type: 'activity',
sourceTabId: 'other-tab',
createdAt: Date.now()
});
tick(750);
expect(dialog.open).not.toHaveBeenCalled();
tick(250);
expect(dialog.open).toHaveBeenCalledTimes(1);
}));
it('should continue session when inbound continue message received', fakeAsync(() => {
configureTestingModule({ enabled: true, idleTimeoutMs: 1000 });
startService();
tick(1000);
expect(dialog.open).toHaveBeenCalledTimes(1);
// When continue is received, the dialog closes without a value
// We need to complete the dialog observable to avoid the EmptyError
const dialogCloseSpy = jasmine.createSpy('dialogClose');
const mockDialogRef = dialog.open.calls.mostRecent().returnValue;
mockDialogRef.close = dialogCloseSpy;
syncChannel.messages$.next({
type: 'continue',
sourceTabId: 'other-tab',
createdAt: Date.now()
});
expect(dialogCloseSpy).toHaveBeenCalled();
// Session should continue, new timeout scheduled
tick(999);
expect(dialog.open).toHaveBeenCalledTimes(1);
tick(1);
expect(dialog.open).toHaveBeenCalledTimes(2);
}));
it('should open dialog when inbound timeout message received without re-broadcasting', fakeAsync(() => {
configureTestingModule({ enabled: true, idleTimeoutMs: 1000 });
const service = startService();
(service as any).lastActivityAt = Date.now() - 500;
syncChannel.messages$.next({
type: 'timeout',
sourceTabId: 'other-tab',
createdAt: Date.now()
});
expect(dialog.open).toHaveBeenCalledTimes(1);
expect(syncChannel.post).not.toHaveBeenCalledWith('timeout');
}));
it('should not open dialog when inbound timeout message is stale', fakeAsync(() => {
configureTestingModule({ enabled: true, idleTimeoutMs: 1000 });
const service = startService();
(service as any).lastActivityAt = Date.now();
syncChannel.messages$.next({
type: 'timeout',
sourceTabId: 'other-tab',
createdAt: Date.now() - 1000
});
expect(dialog.open).not.toHaveBeenCalled();
}));
it('should logout when inbound logout message received without re-broadcasting', fakeAsync(() => {
configureTestingModule({ enabled: true, idleTimeoutMs: 1000 });
startService();
syncChannel.messages$.next({
type: 'logout',
sourceTabId: 'other-tab',
createdAt: Date.now()
});
expect(authService.logout).toHaveBeenCalledTimes(1);
expect(syncChannel.post).not.toHaveBeenCalledWith('logout');
}));
it('should re-evaluate timeout when visibility changes to visible', fakeAsync(() => {
configureTestingModule({ enabled: true, idleTimeoutMs: 1000 });
startService();
tick(500);
// Simulate elapsed time during hidden state
const service = TestBed.inject(SessionTimeoutService);
(service as any).lastActivityAt = Date.now() - 1001;
tracker.visibilityChange$.next('visible');
flushMicrotasks();
expect(dialog.open).toHaveBeenCalledTimes(1);
}));
it('should clear state and stop tracker on logout event', fakeAsync(() => {
configureTestingModule({ enabled: true, idleTimeoutMs: 1000 });
startService();
tick(1000);
expect(dialog.open).toHaveBeenCalledTimes(1);
authService.onLogout.next({});
flushMicrotasks();
expect(tracker.stop).toHaveBeenCalledTimes(1);
expect(syncChannel.close).toHaveBeenCalledTimes(1);
}));
it('should tear down resources on ngOnDestroy', fakeAsync(() => {
configureTestingModule({ enabled: true, idleTimeoutMs: 1000 });
const service = startService();
tick(1000);
expect(dialog.open).toHaveBeenCalledTimes(1);
service.ngOnDestroy();
expect(tracker.stop).toHaveBeenCalledTimes(1);
expect(syncChannel.close).toHaveBeenCalledTimes(1);
}));
it('should not logout when dialog is closed without explicit choice', fakeAsync(() => {
configureTestingModule({ enabled: true, idleTimeoutMs: 1000 });
startService();
tick(1000);
dialogClosed$.next(undefined);
flushMicrotasks();
expect(authService.logout).not.toHaveBeenCalled();
}));
it('should use provider options over app config options', fakeAsync(() => {
configureTestingModule({ enabled: false, idleTimeoutMs: 1000 }, { enabled: true, idleTimeoutMs: 500 });
startService();
tick(500);
expect(dialog.open).toHaveBeenCalledTimes(1);
}));
it('should post timeout message when dialog opens', fakeAsync(() => {
configureTestingModule({ enabled: true, idleTimeoutMs: 1000 });
startService();
tick(1000);
expect(syncChannel.post).toHaveBeenCalledWith('timeout');
}));
});
@@ -0,0 +1,370 @@
/*!
* @license
* Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Injectable, NgZone, OnDestroy, inject } from '@angular/core';
import { MatDialog, MatDialogRef } from '@angular/material/dialog';
import { Subscription } from 'rxjs';
import { take } from 'rxjs/operators';
import { AppConfigService } from '../../app-config/app-config.service';
import { AuthenticationService } from '../services/authentication.service';
import {
SESSION_TIMEOUT_CONFIG_KEY,
SESSION_TIMEOUT_OPTIONS,
SessionTimeoutOptions,
DEFAULT_SESSION_TIMEOUT_OPTIONS,
normalizeSessionTimeoutOptions
} from './session-timeout.config';
import { IdleActivityTracker } from './idle-activity-tracker';
import { SessionTimeoutSyncChannel, SessionTimeoutSyncEvent } from './session-timeout-sync-channel';
import { SessionTimeoutDialogComponent, SessionTimeoutDialogData, SESSION_TIMEOUT_BACKDROP_CLASS } from './session-timeout-dialog.component';
const ACTIVITY_SYNC_THROTTLE_MS = 1000;
@Injectable()
export class SessionTimeoutService implements OnDestroy {
private readonly appConfigService = inject(AppConfigService);
private readonly authService = inject(AuthenticationService);
private readonly dialog = inject(MatDialog);
private readonly ngZone = inject(NgZone);
private readonly providerOptions = inject<SessionTimeoutOptions>(SESSION_TIMEOUT_OPTIONS);
private readonly tracker = inject(IdleActivityTracker);
private readonly syncChannel = inject(SessionTimeoutSyncChannel);
private readonly subscription = new Subscription();
private sessionSubscription: Subscription | undefined;
private timeoutId: ReturnType<typeof setTimeout> | undefined;
private dialogTimeoutId: ReturnType<typeof setTimeout> | undefined;
private dialogRef: MatDialogRef<SessionTimeoutDialogComponent, boolean> | undefined;
private idleTimeoutMs = DEFAULT_SESSION_TIMEOUT_OPTIONS.idleTimeoutMs;
private dialogTimeoutMs = DEFAULT_SESSION_TIMEOUT_OPTIONS.dialogTimeoutMs;
private lastActivityAt = Date.now();
private isStarted = false;
private isSessionActive = false;
private isLoggingOut = false;
private lastActivitySyncAt = 0;
start(): void {
if (this.isStarted) {
return;
}
if (this.appConfigService.isLoaded === false) {
this.appConfigService.onLoad.pipe(take(1)).subscribe(() => {
this.startFromLoadedConfig();
});
return;
}
this.startFromLoadedConfig();
}
ngOnDestroy(): void {
this.clearTimeout();
this.clearDialogTimeout();
this.dialogRef?.close();
this.tracker.stop();
this.syncChannel.close();
this.sessionSubscription?.unsubscribe();
this.subscription.unsubscribe();
}
private startFromLoadedConfig(): void {
if (this.isStarted) {
return;
}
const appConfigOptions = this.appConfigService.get(SESSION_TIMEOUT_CONFIG_KEY, {}) as SessionTimeoutOptions;
const mergedOptions = {
...DEFAULT_SESSION_TIMEOUT_OPTIONS,
...appConfigOptions,
...this.providerOptions
};
const options = normalizeSessionTimeoutOptions(mergedOptions);
if (!options.enabled) {
return;
}
this.idleTimeoutMs = options.idleTimeoutMs;
this.dialogTimeoutMs = options.dialogTimeoutMs;
this.isStarted = true;
this.subscribeToAuthEvents();
if (this.authService.isLoggedIn()) {
this.isSessionActive = true;
this.isLoggingOut = false;
this.activateSessionTimeout();
}
}
private subscribeToAuthEvents(): void {
this.subscription.add(
this.authService.onLogin.subscribe(() => {
const isLoggedIn = this.authService.isLoggedIn();
if (!isLoggedIn) {
this.clearSessionState();
return;
}
this.isSessionActive = true;
this.isLoggingOut = false;
this.activateSessionTimeout();
})
);
this.subscription.add(
this.authService.onLogout.subscribe(() => {
this.clearSessionState();
})
);
}
private activateSessionTimeout(): void {
this.ngZone.runOutsideAngular(() => {
if (!this.canArmSessionTimeout()) {
return;
}
if (this.sessionSubscription) {
this.refreshSession();
return;
}
this.sessionSubscription = new Subscription();
this.tracker.start();
this.syncChannel.open();
this.subscribeToTrackerEvents();
this.subscribeToSyncEvents();
this.refreshSession();
});
}
private subscribeToTrackerEvents(): void {
this.sessionSubscription?.add(
this.tracker.activity$.subscribe(() => {
this.handleActivity();
})
);
this.sessionSubscription?.add(
this.tracker.visibilityChange$.subscribe((state) => {
this.handleVisibilityChange(state);
})
);
}
private subscribeToSyncEvents(): void {
this.sessionSubscription?.add(
this.syncChannel.messages$.subscribe((syncEvent) => {
this.handleSyncEvent(syncEvent);
})
);
}
private handleActivity(): void {
if (this.dialogRef) {
return;
}
this.refreshSession({ shouldNotifyActivity: true });
}
private handleVisibilityChange(state: DocumentVisibilityState): void {
if (state === 'visible') {
this.handleTimeout();
}
}
private handleSyncEvent(syncEvent: SessionTimeoutSyncEvent): void {
if (!this.canArmSessionTimeout() && syncEvent.type !== 'logout') {
return;
}
if (syncEvent.type === 'activity' || syncEvent.type === 'continue') {
this.continueSession();
return;
}
if (syncEvent.type === 'timeout') {
if (syncEvent.createdAt >= this.lastActivityAt) {
this.openContinueWorkingDialog(false);
}
return;
}
this.logout(false);
}
private refreshSession(options?: { shouldNotifyActivity?: boolean }): void {
if (!this.canArmSessionTimeout()) {
return;
}
this.lastActivityAt = Date.now();
if (options?.shouldNotifyActivity === true) {
this.notifyActivity();
}
this.scheduleTimeout();
}
private scheduleTimeout(): void {
this.clearTimeout();
if (!this.canArmSessionTimeout()) {
return;
}
const elapsedMs = Date.now() - this.lastActivityAt;
const remainingMs = Math.max(this.idleTimeoutMs - elapsedMs, 0);
this.timeoutId = setTimeout(() => this.handleTimeout(), remainingMs);
}
private handleTimeout(): void {
if (!this.canArmSessionTimeout()) {
this.clearTimeout();
return;
}
const elapsedMs = Date.now() - this.lastActivityAt;
if (elapsedMs < this.idleTimeoutMs) {
this.scheduleTimeout();
return;
}
this.openContinueWorkingDialog();
}
private openContinueWorkingDialog(shouldNotifyTabs = true): void {
if (this.dialogRef) {
return;
}
if (!this.canArmSessionTimeout()) {
return;
}
this.clearTimeout();
this.dialogRef = this.ngZone.run(() =>
this.dialog.open<SessionTimeoutDialogComponent, SessionTimeoutDialogData, boolean>(SessionTimeoutDialogComponent, {
data: {
dialogTimeoutMs: this.dialogTimeoutMs
},
backdropClass: SESSION_TIMEOUT_BACKDROP_CLASS,
disableClose: true,
width: '420px'
})
);
this.dialogTimeoutId = setTimeout(() => {
this.dialogRef?.close(false);
}, this.dialogTimeoutMs);
if (shouldNotifyTabs) {
this.syncChannel.post('timeout');
}
this.dialogRef
.afterClosed()
.pipe(take(1))
.subscribe((shouldContinueWorking) => {
this.clearDialogTimeout();
this.dialogRef = undefined;
if (shouldContinueWorking === true) {
this.continueSession();
this.syncChannel.post('continue');
return;
}
if (shouldContinueWorking === false) {
this.logout();
}
});
}
private continueSession(): void {
this.closeDialogWithoutAction();
this.refreshSession();
}
private logout(shouldNotifyTabs = true): void {
if (this.isLoggingOut) {
return;
}
this.isSessionActive = false;
this.isLoggingOut = true;
this.clearTimeout();
this.clearDialogTimeout();
if (shouldNotifyTabs) {
this.syncChannel.post('logout');
}
this.ngZone.run(() => this.authService.logout().pipe(take(1)).subscribe());
}
private clearSessionState(): void {
this.isSessionActive = false;
this.dialogRef?.close();
this.clearTimeout();
this.clearDialogTimeout();
this.sessionSubscription?.unsubscribe();
this.sessionSubscription = undefined;
this.tracker.stop();
this.syncChannel.close();
}
private closeDialogWithoutAction(): void {
const dialogRef = this.dialogRef;
if (!dialogRef) {
return;
}
this.dialogRef = undefined;
this.clearDialogTimeout();
dialogRef.close();
}
private canArmSessionTimeout(): boolean {
return this.isSessionActive && !this.isLoggingOut && this.authService.isLoggedIn();
}
private notifyActivity(): void {
const now = Date.now();
if (now - this.lastActivitySyncAt < ACTIVITY_SYNC_THROTTLE_MS) {
return;
}
this.lastActivitySyncAt = now;
this.syncChannel.post('activity');
}
private clearTimeout(): void {
if (this.timeoutId === undefined) {
return;
}
clearTimeout(this.timeoutId);
this.timeoutId = undefined;
}
private clearDialogTimeout(): void {
if (this.dialogTimeoutId === undefined) {
return;
}
clearTimeout(this.dialogTimeoutId);
this.dialogTimeoutId = undefined;
}
}
+6
View File
@@ -667,5 +667,11 @@
"ACTION": "Do you want to proceed?",
"YES_LABEL": "Yes",
"NO_LABEL": "No"
},
"SESSION_TIMEOUT": {
"TITLE": "Are you still working?",
"MESSAGE": "Your session will end in {{seconds}} seconds because there has been no activity.",
"LOG_OUT": "Log out",
"CONTINUE_WORKING": "Continue working"
}
}