mirror of
https://github.com/Alfresco/alfresco-ng2-components.git
synced 2026-09-09 18:03:21 +00:00
AAE-39528 Session timeout features (#12008)
* feat: session timeout features
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user