diff --git a/docs/TIMEOUT_WARNING.md b/docs/TIMEOUT_WARNING.md new file mode 100644 index 0000000000..ebc707a422 --- /dev/null +++ b/docs/TIMEOUT_WARNING.md @@ -0,0 +1,42 @@ +# Session Timeout Warning configuration + +Stark provides a nice feature when it comes to session expiration handling: in case the user's session is about to end due +to inactivity (the user is idle for some time) and you want to warn him about this, a dialog will be displayed. + +This is what the `SessionTimeoutWarningDialogComponent` does and also asks the user if its session should be kept alive. + +This warning dialog is displayed by an NGRX Effect that triggers when a `StarkSessionActionTypes.SESSION_TIMEOUT_COUNTDOWN_START` action +is dispatched (by the Session service when the user becomes idle). + +To use this feature, you'll have to modify the `app.module.ts` file of your application + +## app.module.ts + +You'll have to import the StarkSessionUiModule like follow: + +``` +import {StarkSessionUiModule} from "@nationalbankbelgium/stark-ui"; + +@NgModule({ + imports: [ + StarkSessionUiModule.forRoot(), + ... + ] +}) +``` + +To indicate that you don't wish to display such warning dialog, just set `timeoutWarningDialogDisabled` value to true. +This option is false by default. + +``` +import {StarkSessionUiModule} from "@nationalbankbelgium/stark-ui"; + +@NgModule({ + imports: [ + StarkSessionUiModule.forRoot({ + timeoutWarningDialogDisabled: true + }), + ... + ] +}) +``` diff --git a/packages/stark-build/config/webpack.dev.js b/packages/stark-build/config/webpack.dev.js index 3d4c8ad8c5..25c4058faf 100644 --- a/packages/stark-build/config/webpack.dev.js +++ b/packages/stark-build/config/webpack.dev.js @@ -47,7 +47,7 @@ module.exports = function(env) { // "default-src 'self'", // FIXME: enable as soon as the issue is fixed in Angular (https://github.com/angular/angular-cli/issues/6872 ) "child-src 'self'", "connect-src 'self' ws://" + METADATA.HOST + ":" + METADATA.PORT + " " + webpackCustomConfig["cspConnectSrc"], // ws://HOST:PORT" is due to Webpack - `font-src 'self' ${webpackCustomConfig["cspFontSrc"] || ''}`, + `font-src 'self' ${webpackCustomConfig["cspFontSrc"] || ""}`, "form-action 'self' " + webpackCustomConfig["cspFormAction"], "frame-src 'self'", // deprecated. Use child-src instead. Used here because child-src is not yet supported by Firefox. Remove as soon as it is fully supported "frame-ancestors 'none'", // the app will not be allowed to be embedded in an iframe (roughly equivalent to X-Frame-Options: DENY) @@ -279,14 +279,14 @@ module.exports = function(env) { */ ...(MONITOR ? [ - new WebpackMonitor({ - capture: true, // -> default 'true' - target: helpers.root("reports/webpack-monitor/stats.json"), // default -> '../monitor/stats.json' - launch: true, // -> default 'false' - port: 3030, // default -> 8081 - excludeSourceMaps: true // default 'true' - }) - ] + new WebpackMonitor({ + capture: true, // -> default 'true' + target: helpers.root("reports/webpack-monitor/stats.json"), // default -> '../monitor/stats.json' + launch: true, // -> default 'false' + port: 3030, // default -> 8081 + excludeSourceMaps: true // default 'true' + }) + ] : []) ], diff --git a/packages/stark-ui/assets/styles/_media-queries.scss b/packages/stark-ui/assets/styles/_media-queries.scss index d9b86d91f9..f3ee2456a0 100644 --- a/packages/stark-ui/assets/styles/_media-queries.scss +++ b/packages/stark-ui/assets/styles/_media-queries.scss @@ -7,5 +7,5 @@ $mobile-only-query: "(max-width: 599px)"; $mobile-only-screen-query: "screen and (max-width: 599px)"; $tablet-only-query: "(min-width: 600px) and (max-width: 959px)"; $tablet-only-screen-query: "screen and (min-width: 600px) and (max-width: 959px)"; -$handset-toc-query-screen:"(min-width: 0px) and (max-width: 839px)"; -$tablet-toc-query-screen:"(min-width: 840px) and (max-width: 1279px)"; +$handset-toc-query-screen: "(min-width: 0px) and (max-width: 839px)"; +$tablet-toc-query-screen: "(min-width: 840px) and (max-width: 1279px)"; diff --git a/packages/stark-ui/src/modules/minimap/components/_minimap-theme.scss b/packages/stark-ui/src/modules/minimap/components/_minimap-theme.scss index 48d67614fa..7d992081d6 100644 --- a/packages/stark-ui/src/modules/minimap/components/_minimap-theme.scss +++ b/packages/stark-ui/src/modules/minimap/components/_minimap-theme.scss @@ -33,7 +33,7 @@ & .stark-minimap-dropdown-toggle-menu { border-color: mat-color($grey-palette, 500); box-shadow: 0 1px 2px mat-color($grey-palette, 300); - background-color: #FFF; + background-color: #fff; & mat-checkbox { &:hover { background: $md-primary-alpha-05; @@ -85,7 +85,7 @@ & .stark-minimap-dropdown-toggle-menu { border-color: mat-color($grey-palette, 500); box-shadow: 0 1px 2px mat-color($grey-palette, 300); - background-color: #FFF; + background-color: #fff; & mat-checkbox { &:hover { background: $md-primary-alpha-05; diff --git a/packages/stark-ui/src/modules/session-ui.ts b/packages/stark-ui/src/modules/session-ui.ts index 112375eb84..1ff9f56d13 100644 --- a/packages/stark-ui/src/modules/session-ui.ts +++ b/packages/stark-ui/src/modules/session-ui.ts @@ -1,2 +1,5 @@ export * from "./session-ui/session-ui.module"; export * from "./session-ui/pages"; +export * from "./session-ui/effects"; +export * from "./session-ui/entities"; +export * from "./session-ui/components"; diff --git a/packages/stark-ui/src/modules/session-ui/assets/translations/en.ts b/packages/stark-ui/src/modules/session-ui/assets/translations/en.ts index 56c4569d37..c2259f51f6 100644 --- a/packages/stark-ui/src/modules/session-ui/assets/translations/en.ts +++ b/packages/stark-ui/src/modules/session-ui/assets/translations/en.ts @@ -22,6 +22,13 @@ export const translationsEn: object = { SESSION_LOGOUT: { TITLE: "Logged out", LOGIN: "Log in again" + }, + SESSION_TIMEOUT: { + SECONDS: " seconds.", + STAY_CONNECTED: "Stay connected", + TITLE: "Session about to expire", + WILL_EXPIRE_IN: "Your session will expire in ", + WISH_STAY_CONNECTED: "Do you wish to stay connected?" } } }; diff --git a/packages/stark-ui/src/modules/session-ui/assets/translations/fr.ts b/packages/stark-ui/src/modules/session-ui/assets/translations/fr.ts index 849abd6ff3..7039bf5cbf 100644 --- a/packages/stark-ui/src/modules/session-ui/assets/translations/fr.ts +++ b/packages/stark-ui/src/modules/session-ui/assets/translations/fr.ts @@ -22,6 +22,13 @@ export const translationsFr: object = { SESSION_LOGOUT: { TITLE: "Déconnecté", LOGIN: "Connexion" + }, + SESSION_TIMEOUT: { + SECONDS: " secondes.", + STAY_CONNECTED: "Rester connecté", + TITLE: "Session sur le point d'expirer", + WILL_EXPIRE_IN: "Votre session va expirer dans ", + WISH_STAY_CONNECTED: "Voulez-vous rester connecté?" } } }; diff --git a/packages/stark-ui/src/modules/session-ui/assets/translations/nl.ts b/packages/stark-ui/src/modules/session-ui/assets/translations/nl.ts index d0c7ecd62e..8d57c57d4f 100644 --- a/packages/stark-ui/src/modules/session-ui/assets/translations/nl.ts +++ b/packages/stark-ui/src/modules/session-ui/assets/translations/nl.ts @@ -22,6 +22,13 @@ export const translationsNl: object = { SESSION_LOGOUT: { TITLE: "Afgemeld", LOGIN: "Opnieuw aanmelden" + }, + SESSION_TIMEOUT: { + SECONDS: " seconden vervallen.", + STAY_CONNECTED: "Blijf verbonden", + TITLE: "Sessie verlopen", + WILL_EXPIRE_IN: "Uw sessie zal binnen ", + WISH_STAY_CONNECTED: "Wilt u verbonden blijven?" } } }; diff --git a/packages/stark-ui/src/modules/session-ui/components.ts b/packages/stark-ui/src/modules/session-ui/components.ts new file mode 100644 index 0000000000..ea8b6d947d --- /dev/null +++ b/packages/stark-ui/src/modules/session-ui/components.ts @@ -0,0 +1 @@ +export * from "./components/session-timeout-warning-dialog.component"; diff --git a/packages/stark-ui/src/modules/session-ui/components/_session-timeout-warning-dialog.component.scss b/packages/stark-ui/src/modules/session-ui/components/_session-timeout-warning-dialog.component.scss new file mode 100644 index 0000000000..fd3227592a --- /dev/null +++ b/packages/stark-ui/src/modules/session-ui/components/_session-timeout-warning-dialog.component.scss @@ -0,0 +1,13 @@ +/* ============================================================================== */ +/* S t a r k S e s s i o n T i m e o u t W a r n i n g D i a l o g */ +/* ============================================================================== */ +/* stark-ui: src/modules/session-ui/components/_session-timeout-warning-dialog.component.scss */ + +.stark-timeout-warning-dialog { + margin: 16px auto; + box-sizing: border-box; + border-radius: 8px; + text-align: center; +} + +/* END stark-ui: src/modules/session-ui/components/_session-timeout-warning-dialog.component.scss */ diff --git a/packages/stark-ui/src/modules/session-ui/components/session-timeout-warning-dialog.component.html b/packages/stark-ui/src/modules/session-ui/components/session-timeout-warning-dialog.component.html new file mode 100644 index 0000000000..74cc1e5eac --- /dev/null +++ b/packages/stark-ui/src/modules/session-ui/components/session-timeout-warning-dialog.component.html @@ -0,0 +1,20 @@ +
+

STARK.SESSION_TIMEOUT.TITLE

+
+

+ STARK.SESSION_TIMEOUT.WILL_EXPIRE_IN {{ countdown$|async }} + STARK.SESSION_TIMEOUT.SECONDS +

+

STARK.SESSION_TIMEOUT.WISH_STAY_CONNECTED

+
+
+ +
+
+ + diff --git a/packages/stark-ui/src/modules/session-ui/components/session-timeout-warning-dialog.component.spec.ts b/packages/stark-ui/src/modules/session-ui/components/session-timeout-warning-dialog.component.spec.ts new file mode 100644 index 0000000000..73d5e03f28 --- /dev/null +++ b/packages/stark-ui/src/modules/session-ui/components/session-timeout-warning-dialog.component.spec.ts @@ -0,0 +1,70 @@ +/*tslint:disable:completed-docs*/ +import { async, ComponentFixture, fakeAsync, TestBed, tick } from "@angular/core/testing"; +import { CommonModule } from "@angular/common"; +import { STARK_LOGGING_SERVICE } from "@nationalbankbelgium/stark-core"; +import { MockStarkLoggingService } from "@nationalbankbelgium/stark-core/testing"; +import { MatDialog, MAT_DIALOG_DATA, MatDialogRef, MatDialogModule } from "@angular/material/dialog"; +import { StarkSessionTimeoutWarningDialogComponent } from "./session-timeout-warning-dialog.component"; + +import Spy = jasmine.Spy; +import createSpyObj = jasmine.createSpyObj; +import { Observer } from "rxjs"; + +describe("SessionTimeoutWarningDialogComponent", () => { + let component: StarkSessionTimeoutWarningDialogComponent; + let fixture: ComponentFixture; + + let mockDialogRef: MatDialogRef; + const mockLogger: MockStarkLoggingService = new MockStarkLoggingService(); + + beforeEach(async(() => { + return TestBed.configureTestingModule({ + declarations: [StarkSessionTimeoutWarningDialogComponent], + imports: [CommonModule, MatDialogModule], + providers: [ + { provide: STARK_LOGGING_SERVICE, useValue: mockLogger }, + { provide: MatDialog, useValue: MatDialog }, + { provide: MAT_DIALOG_DATA, useValue: 20 }, + { provide: MatDialogRef, useValue: createSpyObj("MatDialogRefSpy", ["close"]) } + ] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(StarkSessionTimeoutWarningDialogComponent); + mockDialogRef = TestBed.get(MatDialogRef); + component = fixture.componentInstance; + + (mockLogger.debug).calls.reset(); + }); + + describe("ngOnInit", () => { + it("should set the countdown and decrement it every second", fakeAsync(() => { + const mockObserver: Observer = createSpyObj>("observerSpy", ["next", "error", "complete"]); + + component.ngOnInit(); + component.countdown$.subscribe(mockObserver); + + expect(mockLogger.debug).toHaveBeenCalledTimes(1); + + tick(20000); + + expect(mockObserver.next).toHaveBeenCalledTimes(21); + expect(mockObserver.error).not.toHaveBeenCalled(); + expect(mockObserver.complete).toHaveBeenCalled(); + + expect(mockDialogRef.close).toHaveBeenCalledTimes(1); + expect(mockDialogRef.close).toHaveBeenCalledWith("countdown-finished"); + })); + }); + + describe("keepSession", () => { + it("should close the windows when the button is clicked", fakeAsync(() => { + component.ngOnInit(); + component.keepSession(); + + expect(mockDialogRef.close).toHaveBeenCalledTimes(1); + expect(mockDialogRef.close).toHaveBeenCalledWith("keep-logged"); + })); + }); +}); diff --git a/packages/stark-ui/src/modules/session-ui/components/session-timeout-warning-dialog.component.ts b/packages/stark-ui/src/modules/session-ui/components/session-timeout-warning-dialog.component.ts new file mode 100644 index 0000000000..2864f6f975 --- /dev/null +++ b/packages/stark-ui/src/modules/session-ui/components/session-timeout-warning-dialog.component.ts @@ -0,0 +1,55 @@ +import { Component, Inject, OnInit, ViewEncapsulation } from "@angular/core"; +import { MAT_DIALOG_DATA, MatDialogRef } from "@angular/material/dialog"; +import { Observable, interval } from "rxjs"; +import { STARK_LOGGING_SERVICE, StarkLoggingService } from "@nationalbankbelgium/stark-core"; +import { map, startWith, tap, take } from "rxjs/operators"; + +/** + * The name of the component + */ +const componentName: string = "stark-session-timeout-warning-dialog"; + +/** + * Component to display a session timeout warning dialog + */ +@Component({ + selector: "session-timeout-warning-dialog", + templateUrl: "./session-timeout-warning-dialog.component.html", + encapsulation: ViewEncapsulation.None +}) +export class StarkSessionTimeoutWarningDialogComponent implements OnInit { + /** + * countdown Time in seconds before the current session expires. + */ + public countdown$: Observable; + + public constructor( + @Inject(STARK_LOGGING_SERVICE) public logger: StarkLoggingService, + @Inject(MatDialogRef) private dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) private coutdown: number + ) {} + + /** + * Component lifecycle hook + */ + public ngOnInit(): void { + this.logger.debug(componentName + ": controller initialized"); + this.countdown$ = interval(1000).pipe( + take(this.coutdown), + startWith(-1), + map((value: number) => this.coutdown - value - 1), // -1 due to the delay of the dialog animation + tap((value: number) => { + if (value === 0) { + this.dialogRef.close("countdown-finished"); + } + }) + ); + } + + /** + * This methods is used to close the dialog and to send an answer indicating that the user should keep logged. + */ + public keepSession(): void { + this.dialogRef.close("keep-logged"); + } +} diff --git a/packages/stark-ui/src/modules/session-ui/effects.ts b/packages/stark-ui/src/modules/session-ui/effects.ts new file mode 100644 index 0000000000..1caddc8346 --- /dev/null +++ b/packages/stark-ui/src/modules/session-ui/effects.ts @@ -0,0 +1 @@ +export * from "./effects/session-timeout-warning.effects"; diff --git a/packages/stark-ui/src/modules/session-ui/effects/session-timeout-warning.effect.spec.ts b/packages/stark-ui/src/modules/session-ui/effects/session-timeout-warning.effect.spec.ts new file mode 100644 index 0000000000..4f4cc6cd4c --- /dev/null +++ b/packages/stark-ui/src/modules/session-ui/effects/session-timeout-warning.effect.spec.ts @@ -0,0 +1,224 @@ +/*tslint:disable:completed-docs no-big-function*/ +import createSpyObj = jasmine.createSpyObj; +import { Observable, ReplaySubject, Observer, Subject } from "rxjs"; +import { async, TestBed } from "@angular/core/testing"; +import { + STARK_SESSION_SERVICE, + StarkSessionService, + StarkSessionTimeoutCountdownStart, + StarkSessionTimeoutCountdownFinish, + StarkInitializeSession +} from "@nationalbankbelgium/stark-core"; +import { MockStarkSessionService } from "@nationalbankbelgium/stark-core/testing"; +import { provideMockActions } from "@ngrx/effects/testing"; +import { EffectNotification } from "@ngrx/effects"; +import { TranslateModule, TranslateService } from "@ngx-translate/core"; +import { MatDialog, MatDialogModule } from "@angular/material/dialog"; +import { MatButtonModule } from "@angular/material/button"; +import { NoopAnimationsModule } from "@angular/platform-browser/animations"; +import { StarkSessionTimeoutWarningDialogComponent } from "../components/session-timeout-warning-dialog.component"; +import { StarkSessionTimeoutWarningDialogEffects } from "../effects/session-timeout-warning.effects"; +import { STARK_SESSION_UI_CONFIG, StarkSessionUiConfig } from "../entities/stark-session-ui-config"; + +import Spy = jasmine.Spy; + +describe("Effects: StarkSessionTimeoutWarningDialogEffects", () => { + let effectsClass: StarkSessionTimeoutWarningDialogEffects; + let mockSessionService: StarkSessionService; + let mockDialogService: MatDialog; + let mockSessionUiConfig: StarkSessionUiConfig; + let actions: Observable; + + beforeEach(async(() => { + return TestBed.configureTestingModule({ + imports: [NoopAnimationsModule, MatDialogModule, TranslateModule.forRoot(), MatButtonModule], + providers: [ + StarkSessionTimeoutWarningDialogEffects, + provideMockActions(() => actions), + TranslateService, + { + provide: MatDialog, + useValue: createSpyObj("MatDialogSpy", ["open", "close", "closeAll"]) + }, + { provide: StarkSessionTimeoutWarningDialogComponent, useValue: StarkSessionTimeoutWarningDialogComponent }, + { provide: STARK_SESSION_SERVICE, useFactory: () => new MockStarkSessionService() }, + { provide: STARK_SESSION_UI_CONFIG, useValue: new StarkSessionUiConfig() } + ] + }).compileComponents(); + })); + + beforeEach(() => { + effectsClass = TestBed.get(StarkSessionTimeoutWarningDialogEffects); + mockSessionService = TestBed.get(STARK_SESSION_SERVICE); + mockDialogService = TestBed.get(MatDialog); + mockSessionUiConfig = TestBed.get(STARK_SESSION_UI_CONFIG); + }); + + describe("on initialization", () => { + it("should set internal component properties", () => { + expect(effectsClass.sessionService).not.toBeNull(); + expect(effectsClass.sessionService).toBeDefined(); + expect(effectsClass.starkSessionUiConfig).not.toBeNull(); + expect(effectsClass.starkSessionUiConfig).toBeDefined(); + }); + }); + + describe("On StarkSessionTimeoutWarning$", () => { + it("Should open a dialog when the timeout countdown begins", () => { + const afterClosedResult: string = "keep-logged"; + const afterClosed$: Subject = new Subject(); + + (mockDialogService.open).and.returnValue({ + afterClosed: () => { + return afterClosed$; + } + }); + + const mockObserver: Observer = createSpyObj>("observerSpy", ["next", "error", "complete"]); + const subject: ReplaySubject = new ReplaySubject(1); + actions = subject.asObservable(); + + effectsClass.starkSessionTimeoutWarning$().subscribe(mockObserver); + + expect(mockSessionService.pauseUserActivityTracking).not.toHaveBeenCalled(); + expect(mockDialogService.open).not.toHaveBeenCalled(); + + subject.next(new StarkSessionTimeoutCountdownStart(20)); + + expect(mockSessionService.pauseUserActivityTracking).toHaveBeenCalled(); + expect(mockObserver.next).toHaveBeenCalledTimes(1); + expect(mockObserver.error).not.toHaveBeenCalled(); + expect(mockObserver.complete).not.toHaveBeenCalled(); + expect(mockDialogService.open).toHaveBeenCalledTimes(1); + expect(mockDialogService.open).toHaveBeenCalledWith(StarkSessionTimeoutWarningDialogComponent, { data: 20 }); + + expect(mockSessionService.resumeUserActivityTracking).not.toHaveBeenCalled(); + + afterClosed$.next(afterClosedResult); + + expect(mockSessionService.resumeUserActivityTracking).toHaveBeenCalledTimes(1); + }); + }); + + describe("On StarkSessionTimeoutWarningClose$", () => { + it("Should close the dialog when the countdown finishes", () => { + const mockObserver: Observer = createSpyObj>("observerSpy", ["next", "error", "complete"]); + + const subject: ReplaySubject = new ReplaySubject(1); + actions = subject.asObservable(); + + effectsClass.starkSessionTimeoutWarningClose$().subscribe(mockObserver); + + expect(mockDialogService.closeAll).not.toHaveBeenCalled(); + + subject.next(new StarkSessionTimeoutCountdownFinish()); + + expect(mockObserver.next).toHaveBeenCalledTimes(1); + expect(mockObserver.error).not.toHaveBeenCalled(); + expect(mockObserver.complete).not.toHaveBeenCalled(); + expect(mockDialogService.closeAll).toHaveBeenCalledTimes(1); + }); + }); + + describe("On ngrxOnRunEffects", () => { + it("Should stop the effects immediately when the option timeoutWarningDialogDisabled is true", () => { + const mockObserver: Observer = createSpyObj>("observerSpy", ["next", "error", "complete"]); + + const actions$: ReplaySubject = new ReplaySubject(1); + actions = actions$.asObservable(); + spyOn(effectsClass.actions$, "pipe").and.callThrough(); + + mockSessionUiConfig.timeoutWarningDialogDisabled = true; + + const mockResolvedEffectsSubject: Subject = new Subject(); + const mockResolvedEffects$: Observable = mockResolvedEffectsSubject.asObservable(); + + const resolvedEffectsObservable: Observable = effectsClass.ngrxOnRunEffects(mockResolvedEffects$); + expect(effectsClass.actions$.pipe).toHaveBeenCalledTimes(1); + + resolvedEffectsObservable.subscribe(mockObserver); + + (mockObserver.next).calls.reset(); + + actions$.next("dummy acton"); + actions$.next("another dummy acton"); + + actions$.next(new StarkInitializeSession({})); + mockResolvedEffectsSubject.next("this should never be emitted"); + + expect(mockObserver.next).not.toHaveBeenCalled(); + expect(mockObserver.error).not.toHaveBeenCalled(); + expect(mockObserver.complete).not.toHaveBeenCalled(); + }); + + it("Should run the effects immediately when the option timeoutWarningDialogDisabled is false", () => { + const mockObserver: Observer = createSpyObj>("observerSpy", ["next", "error", "complete"]); + + const actions$: ReplaySubject = new ReplaySubject(1); + actions = actions$.asObservable(); + spyOn(effectsClass.actions$, "pipe").and.callThrough(); + + mockSessionUiConfig.timeoutWarningDialogDisabled = false; + + const mockResolvedEffectsSubject: Subject = new Subject(); + const mockResolvedEffects$: Observable = mockResolvedEffectsSubject.asObservable(); + + const resolvedEffectsObservable: Observable = effectsClass.ngrxOnRunEffects(mockResolvedEffects$); + expect(effectsClass.actions$.pipe).toHaveBeenCalledTimes(1); + + resolvedEffectsObservable.subscribe(mockObserver); + + actions$.next("dummy initial action1"); + mockResolvedEffectsSubject.next("dummy resolved effect"); + + expect(mockObserver.next).toHaveBeenCalled(); + expect(mockObserver.error).not.toHaveBeenCalled(); + expect(mockObserver.complete).not.toHaveBeenCalled(); + + (mockObserver.next).calls.reset(); + + // the effects should keep on running with any action + actions$.next("dummy action1"); + mockResolvedEffectsSubject.next("another dummy resolved effect"); + + expect(mockObserver.next).toHaveBeenCalled(); + expect(mockObserver.error).not.toHaveBeenCalled(); + expect(mockObserver.complete).not.toHaveBeenCalled(); + }); + + it("Should run the effects immediately when the option timeoutWarningDialogDisabled is undefined", () => { + const mockObserver: Observer = createSpyObj>("observerSpy", ["next", "error", "complete"]); + + const actions$: ReplaySubject = new ReplaySubject(1); + actions = actions$.asObservable(); + spyOn(effectsClass.actions$, "pipe").and.callThrough(); + + mockSessionUiConfig.timeoutWarningDialogDisabled = undefined; + + const mockResolvedEffectsSubject: Subject = new Subject(); + const mockResolvedEffects$: Observable = mockResolvedEffectsSubject.asObservable(); + + const resolvedEffectsObservable: Observable = effectsClass.ngrxOnRunEffects(mockResolvedEffects$); + expect(effectsClass.actions$.pipe).toHaveBeenCalledTimes(1); + + resolvedEffectsObservable.subscribe(mockObserver); + + actions$.next("dummy initial action1"); + mockResolvedEffectsSubject.next("dummy resolved effect"); + + expect(mockObserver.next).toHaveBeenCalled(); + expect(mockObserver.error).not.toHaveBeenCalled(); + expect(mockObserver.complete).not.toHaveBeenCalled(); + + (mockObserver.next).calls.reset(); + + // the effects should keep on running with any action + actions$.next("dummy action1"); + mockResolvedEffectsSubject.next("another dummy resolved effect"); + + expect(mockObserver.next).toHaveBeenCalled(); + expect(mockObserver.error).not.toHaveBeenCalled(); + expect(mockObserver.complete).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/stark-ui/src/modules/session-ui/effects/session-timeout-warning.effects.ts b/packages/stark-ui/src/modules/session-ui/effects/session-timeout-warning.effects.ts new file mode 100644 index 0000000000..e72cb00521 --- /dev/null +++ b/packages/stark-ui/src/modules/session-ui/effects/session-timeout-warning.effects.ts @@ -0,0 +1,84 @@ +import { Inject, Injectable, Optional } from "@angular/core"; +import { Observable, of } from "rxjs"; +import { exhaustMap, map, takeUntil } from "rxjs/operators"; +import { + STARK_SESSION_SERVICE, + StarkSessionActionTypes, + StarkSessionService, + StarkSessionTimeoutCountdownStart, + StarkSessionTimeoutCountdownFinish +} from "@nationalbankbelgium/stark-core"; +import { StarkSessionTimeoutWarningDialogComponent } from "../components/session-timeout-warning-dialog.component"; +import { MatDialog } from "@angular/material/dialog"; +import { Actions, Effect, EffectNotification, ofType, OnRunEffects } from "@ngrx/effects"; +import { STARK_SESSION_UI_CONFIG, StarkSessionUiConfig } from "../entities/stark-session-ui-config"; + +@Injectable() +export class StarkSessionTimeoutWarningDialogEffects implements OnRunEffects { + /** + * Show a warning dialog when the Timeout countdown stars (the user activity tracking is paused until the user closes the dialog) + */ + public constructor( + public actions$: Actions, + @Inject(STARK_SESSION_SERVICE) public sessionService: StarkSessionService, + @Optional() + @Inject(STARK_SESSION_UI_CONFIG) + public starkSessionUiConfig: StarkSessionUiConfig, + @Inject(MatDialog) public dialogService: MatDialog + ) {} + + /** + * This method is used to display a warning session timeout dialog. + * When the dialog is closed, if the "keep-logged" string is received as a result, it means that the user should keep logged + * and we resume the user activity tracking. + */ + @Effect({ dispatch: false }) + public starkSessionTimeoutWarning$(): Observable { + return this.actions$.pipe( + ofType(StarkSessionActionTypes.SESSION_TIMEOUT_COUNTDOWN_START), + map((action: StarkSessionTimeoutCountdownStart) => { + this.sessionService.pauseUserActivityTracking(); + this.dialogService + .open(StarkSessionTimeoutWarningDialogComponent, { data: action.countdown }) + .afterClosed() + .subscribe((result: string) => { + if (result && result === "keep-logged") { + this.sessionService.resumeUserActivityTracking(); + } + }); + }) + ); + } + + /** + * This method is used to close the dialog if the session timeout countdown has finished + */ + @Effect({ dispatch: false }) + public starkSessionTimeoutWarningClose$(): Observable { + return this.actions$.pipe( + ofType(StarkSessionActionTypes.SESSION_TIMEOUT_COUNTDOWN_FINISH), + map(() => { + this.dialogService.closeAll(); + }) + ); + } + + /** + * This method will be triggered before the session is open to determine if we should use the session timeout warning effect or not. + */ + public ngrxOnRunEffects(resolvedEffects$: Observable): Observable { + if (this.starkSessionUiConfig && this.starkSessionUiConfig.timeoutWarningDialogDisabled === true) { + return this.actions$.pipe( + exhaustMap(() => { + return resolvedEffects$.pipe(takeUntil(of("stop"))); + }) + ); + } else { + return this.actions$.pipe( + exhaustMap(() => { + return resolvedEffects$; + }) + ); + } + } +} diff --git a/packages/stark-ui/src/modules/session-ui/entities.ts b/packages/stark-ui/src/modules/session-ui/entities.ts new file mode 100644 index 0000000000..4bd41d1679 --- /dev/null +++ b/packages/stark-ui/src/modules/session-ui/entities.ts @@ -0,0 +1 @@ +export * from "./entities/stark-session-ui-config"; diff --git a/packages/stark-ui/src/modules/session-ui/entities/stark-session-ui-config.ts b/packages/stark-ui/src/modules/session-ui/entities/stark-session-ui-config.ts new file mode 100644 index 0000000000..95dc5f952f --- /dev/null +++ b/packages/stark-ui/src/modules/session-ui/entities/stark-session-ui-config.ts @@ -0,0 +1,19 @@ +import { InjectionToken } from "@angular/core"; + +/** + * The InjectionToken version of the config name + */ +export const STARK_SESSION_UI_CONFIG: InjectionToken = new InjectionToken( + "StarkSessionUiConfig" +); + +/** + * Definition of the configuration object for the Stark Session UI module + */ +export class StarkSessionUiConfig { + /** + * Whether the warning dialog should be displayed or not. + * If true, then the Ngrx Effects in charge of displaying the dialog won't be executed. + */ + public timeoutWarningDialogDisabled?: boolean; +} diff --git a/packages/stark-ui/src/modules/session-ui/pages/preloading/preloading-page.component.ts b/packages/stark-ui/src/modules/session-ui/pages/preloading/preloading-page.component.ts index 0f418afd74..89d7f63d6f 100644 --- a/packages/stark-ui/src/modules/session-ui/pages/preloading/preloading-page.component.ts +++ b/packages/stark-ui/src/modules/session-ui/pages/preloading/preloading-page.component.ts @@ -21,7 +21,7 @@ const componentName: string = "stark-preloading-page"; /** * Preloading Page smart component. - * + * * This page will be shown when the application starts and will fetch the user profile (via the {@link StarkUserService}) to perform the login of the user. * It will redirect to the target page (via the {@link StarkRoutingService}) as soon as the user profile is loaded and logged in. */ diff --git a/packages/stark-ui/src/modules/session-ui/pages/session-expired/session-expired-page.component.ts b/packages/stark-ui/src/modules/session-ui/pages/session-expired/session-expired-page.component.ts index b6295d4b75..66229091f4 100644 --- a/packages/stark-ui/src/modules/session-ui/pages/session-expired/session-expired-page.component.ts +++ b/packages/stark-ui/src/modules/session-ui/pages/session-expired/session-expired-page.component.ts @@ -9,7 +9,7 @@ const componentName: string = "stark-session-expired-page"; /** * Session Expired Page smart component. - * + * * This page will be shown when there is no user activity in the application and the session expiration timer has timed out (see {@link StarKApplicationConfig}). * In this page, the user has the ability to reload again the application clicking the Reload button. */ diff --git a/packages/stark-ui/src/modules/session-ui/pages/session-logout/session-logout-page.component.ts b/packages/stark-ui/src/modules/session-ui/pages/session-logout/session-logout-page.component.ts index 133ef21e98..394ff85dd6 100644 --- a/packages/stark-ui/src/modules/session-ui/pages/session-logout/session-logout-page.component.ts +++ b/packages/stark-ui/src/modules/session-ui/pages/session-logout/session-logout-page.component.ts @@ -9,8 +9,8 @@ const componentName: string = "stark-session-logout-page"; /** * Session Logout Page smart component. - * - * This page will be shown when the user logs out from the application (i.e. clicking the {@AppLogoComponent} button). + * + * This page will be shown when the user logs out from the application (i.e. clicking the {@AppLogoComponent} button). * In this page, the user has the ability to reload and log in again into the application by clicking the Login button. */ @Component({ diff --git a/packages/stark-ui/src/modules/session-ui/session-ui.module.ts b/packages/stark-ui/src/modules/session-ui/session-ui.module.ts index dc2d3c0a34..ce8afa522e 100644 --- a/packages/stark-ui/src/modules/session-ui/session-ui.module.ts +++ b/packages/stark-ui/src/modules/session-ui/session-ui.module.ts @@ -3,7 +3,6 @@ import { UIRouterModule } from "@uirouter/angular"; import { TranslateModule, TranslateService } from "@ngx-translate/core"; import { CommonModule } from "@angular/common"; import { MatButtonModule } from "@angular/material/button"; -import { StarkLocale } from "@nationalbankbelgium/stark-core"; import { SESSION_UI_STATES } from "./routes"; import { StarkLoginPageComponent, @@ -16,22 +15,39 @@ import { translationsFr } from "./assets/translations/fr"; import { translationsNl } from "./assets/translations/nl"; import { mergeUiTranslations } from "../../common/translations"; +import { STARK_SESSION_UI_CONFIG, StarkSessionUiConfig } from "./entities"; +import { StarkSessionTimeoutWarningDialogComponent } from "./components/session-timeout-warning-dialog.component"; +import { MatDialogModule } from "@angular/material/dialog"; +import { StarkLocale } from "@nationalbankbelgium/stark-core"; +import { EffectsModule } from "@ngrx/effects"; +import { StarkSessionTimeoutWarningDialogEffects } from "./effects"; + @NgModule({ declarations: [ StarkLoginPageComponent, StarkPreloadingPageComponent, StarkSessionExpiredPageComponent, - StarkSessionLogoutPageComponent + StarkSessionLogoutPageComponent, + StarkSessionTimeoutWarningDialogComponent + ], + exports: [ + StarkLoginPageComponent, + StarkPreloadingPageComponent, + StarkSessionExpiredPageComponent, + StarkSessionLogoutPageComponent, + StarkSessionTimeoutWarningDialogComponent ], - exports: [StarkLoginPageComponent, StarkPreloadingPageComponent, StarkSessionExpiredPageComponent, StarkSessionLogoutPageComponent], imports: [ CommonModule, UIRouterModule.forChild({ states: SESSION_UI_STATES }), MatButtonModule, - TranslateModule - ] + MatDialogModule, + TranslateModule, + EffectsModule.forFeature([StarkSessionTimeoutWarningDialogEffects]) + ], + entryComponents: [StarkSessionTimeoutWarningDialogComponent] }) export class StarkSessionUiModule { /** @@ -40,9 +56,10 @@ export class StarkSessionUiModule { * @link https://angular.io/guide/singleton-services#forroot * @returns a module with providers */ - public static forRoot(): ModuleWithProviders { + public static forRoot(starkSessionUiConfig?: StarkSessionUiConfig): ModuleWithProviders { return { - ngModule: StarkSessionUiModule + ngModule: StarkSessionUiModule, + providers: [starkSessionUiConfig ? { provide: STARK_SESSION_UI_CONFIG, useValue: starkSessionUiConfig } : []] }; } diff --git a/showcase/src/app/getting-started/getting-started-component/_getting-started-theme.scss b/showcase/src/app/getting-started/getting-started-component/_getting-started-theme.scss index f9b9bd7089..1c2e4bf0f1 100644 --- a/showcase/src/app/getting-started/getting-started-component/_getting-started-theme.scss +++ b/showcase/src/app/getting-started/getting-started-component/_getting-started-theme.scss @@ -24,7 +24,7 @@ .custom-pretty-print { & pre { background-color: $custom-dark-background; - border-radius: 0.3em; + border-radius: 0.3em; } color: mat-color($grey-palette, 100); } diff --git a/showcase/src/app/shared/table-of-contents/table-of-contents.component.scss b/showcase/src/app/shared/table-of-contents/table-of-contents.component.scss index bd161086a3..219400b959 100644 --- a/showcase/src/app/shared/table-of-contents/table-of-contents.component.scss +++ b/showcase/src/app/shared/table-of-contents/table-of-contents.component.scss @@ -27,33 +27,33 @@ cursor: pointer; } -@media #{$desktop-lg-query} { +@media #{$desktop-lg-query} { .table-of-contents-container { - padding: 5px 0 10px 10px; - width: 200px; - position: fixed; - top: 145px; - transform: translateX(375%); + padding: 5px 0 10px 10px; + width: 200px; + position: fixed; + top: 145px; + transform: translateX(375%); } } -@media #{$handset-toc-query-screen}{ +@media #{$handset-toc-query-screen} { .table-of-contents-container { - padding: 5px 0 10px 10px; + padding: 5px 0 10px 10px; width: 500px; left: 40px; position: inherit; - display: inline-table;; + display: inline-table; } } @media #{$tablet-toc-query-screen} { .table-of-contents-container { - padding: 5px 0 10px 10px; - position: fixed; - top: 145px; - width: 200px; - display: inline-table; - right: 0; + padding: 5px 0 10px 10px; + position: fixed; + top: 145px; + width: 200px; + display: inline-table; + right: 0; } } diff --git a/starter/src/styles/_stark-styles.scss b/starter/src/styles/_stark-styles.scss index 4bab25c686..1c2e0fd391 100644 --- a/starter/src/styles/_stark-styles.scss +++ b/starter/src/styles/_stark-styles.scss @@ -17,6 +17,7 @@ IMPORTANT: Stark styles are provided as SCSS styles so they should be imported i @import "~@nationalbankbelgium/stark-ui/src/modules/language-selector/components/language-selector.component"; @import "~@nationalbankbelgium/stark-ui/src/modules/toast-notification/components/toast-notification.component"; @import "~@nationalbankbelgium/stark-ui/src/modules/toast-notification/components/toast-notification-theme"; +@import "~@nationalbankbelgium/stark-ui/src/modules/session-ui/components/session-timeout-warning-dialog.component"; /* Stark session-ui pages */ @import "~@nationalbankbelgium/stark-ui/src/modules/session-ui/pages/login/login-page.component";