diff --git a/npm/ng-packs/packages/core/src/lib/actions/session.actions.ts b/npm/ng-packs/packages/core/src/lib/actions/session.actions.ts index 7a89be4de3..463e9a202d 100644 --- a/npm/ng-packs/packages/core/src/lib/actions/session.actions.ts +++ b/npm/ng-packs/packages/core/src/lib/actions/session.actions.ts @@ -8,3 +8,11 @@ export class SetTenant { static readonly type = '[Session] Set Tenant'; constructor(public payload: ABP.BasicItem) {} } +export class ModifyOpenedTabCount { + static readonly type = '[Session] Modify Opened Tab Count'; + constructor(public operation: 'increase' | 'decrease') {} +} +export class SetRemember { + static readonly type = '[Session] Set Remember'; + constructor(public payload: boolean) {} +} diff --git a/npm/ng-packs/packages/core/src/lib/core.module.ts b/npm/ng-packs/packages/core/src/lib/core.module.ts index a3fc08c7e2..e89da17f29 100644 --- a/npm/ng-packs/packages/core/src/lib/core.module.ts +++ b/npm/ng-packs/packages/core/src/lib/core.module.ts @@ -6,7 +6,7 @@ import { RouterModule } from '@angular/router'; import { NgxsRouterPluginModule } from '@ngxs/router-plugin'; import { NgxsStoragePluginModule } from '@ngxs/storage-plugin'; import { NgxsModule, NGXS_PLUGINS } from '@ngxs/store'; -import { OAuthModule } from 'angular-oauth2-oidc'; +import { OAuthModule, OAuthStorage } from 'angular-oauth2-oidc'; import { AbstractNgModelComponent } from './abstracts/ng-model.component'; import { DynamicLayoutComponent } from './components/dynamic-layout.component'; import { RouterOutletComponent } from './components/router-outlet.component'; @@ -34,12 +34,15 @@ import { ReplaceableComponentsState } from './states/replaceable-components.stat import { InitDirective } from './directives/init.directive'; import { ReplaceableTemplateDirective } from './directives/replaceable-template.directive'; +export function storageFactory(): OAuthStorage { + return localStorage; +} @NgModule({ imports: [ NgxsModule.forFeature([ReplaceableComponentsState, ProfileState, SessionState, ConfigState]), NgxsRouterPluginModule.forRoot(), NgxsStoragePluginModule.forRoot({ key: ['SessionState'] }), - OAuthModule.forRoot(), + OAuthModule, CommonModule, HttpClientModule, FormsModule, @@ -127,6 +130,8 @@ export class CoreModule { deps: [Injector], useFactory: localeInitializer, }, + ...OAuthModule.forRoot().providers, + { provide: OAuthStorage, useFactory: storageFactory }, ], }; } diff --git a/npm/ng-packs/packages/core/src/lib/models/session.ts b/npm/ng-packs/packages/core/src/lib/models/session.ts index 8eba0cb36c..110fad53f5 100644 --- a/npm/ng-packs/packages/core/src/lib/models/session.ts +++ b/npm/ng-packs/packages/core/src/lib/models/session.ts @@ -4,5 +4,12 @@ export namespace Session { export interface State { language: string; tenant: ABP.BasicItem; + sessionDetail: SessionDetail; + } + + export interface SessionDetail { + openedTabCount: number; + lastExitTime: number; + remember: boolean; } } diff --git a/npm/ng-packs/packages/core/src/lib/services/session-state.service.ts b/npm/ng-packs/packages/core/src/lib/services/session-state.service.ts index 88b8f2df9b..2875e02824 100644 --- a/npm/ng-packs/packages/core/src/lib/services/session-state.service.ts +++ b/npm/ng-packs/packages/core/src/lib/services/session-state.service.ts @@ -1,8 +1,12 @@ import { Injectable } from '@angular/core'; import { Store } from '@ngxs/store'; +import { + SetLanguage, + SetRemember, + SetTenant, + ModifyOpenedTabCount, +} from '../actions/session.actions'; import { SessionState } from '../states'; -import { ABP } from '../models'; -import { SetLanguage, SetTenant } from '../actions'; @Injectable({ providedIn: 'root', @@ -18,6 +22,10 @@ export class SessionStateService { return this.store.selectSnapshot(SessionState.getTenant); } + getSessionDetail() { + return this.store.selectSnapshot(SessionState.getSessionDetail); + } + dispatchSetLanguage(...args: ConstructorParameters) { return this.store.dispatch(new SetLanguage(...args)); } @@ -25,4 +33,12 @@ export class SessionStateService { dispatchSetTenant(...args: ConstructorParameters) { return this.store.dispatch(new SetTenant(...args)); } + + dispatchSetRemember(...args: ConstructorParameters) { + return this.store.dispatch(new SetRemember(...args)); + } + + dispatchModifyOpenedTabCount(...args: ConstructorParameters) { + return this.store.dispatch(new ModifyOpenedTabCount(...args)); + } } diff --git a/npm/ng-packs/packages/core/src/lib/states/session.state.ts b/npm/ng-packs/packages/core/src/lib/states/session.state.ts index 06372c8e7d..7ceaa4a1bc 100644 --- a/npm/ng-packs/packages/core/src/lib/states/session.state.ts +++ b/npm/ng-packs/packages/core/src/lib/states/session.state.ts @@ -1,14 +1,29 @@ -import { Action, Selector, State, StateContext } from '@ngxs/store'; -import { from } from 'rxjs'; -import { switchMap } from 'rxjs/operators'; +import { + Action, + Selector, + State, + StateContext, + Store, + NgxsOnInit, + Actions, + ofActionSuccessful, +} from '@ngxs/store'; +import { from, fromEvent } from 'rxjs'; +import { switchMap, take } from 'rxjs/operators'; import { GetAppConfiguration } from '../actions/config.actions'; -import { SetLanguage, SetTenant } from '../actions/session.actions'; +import { + SetLanguage, + SetTenant, + ModifyOpenedTabCount, + SetRemember, +} from '../actions/session.actions'; import { ABP, Session } from '../models'; import { LocalizationService } from '../services/localization.service'; +import { OAuthService } from 'angular-oauth2-oidc'; @State({ name: 'SessionState', - defaults: {} as Session.State, + defaults: { sessionDetail: { openedTabCount: 0 } } as Session.State, }) export class SessionState { @Selector() @@ -21,7 +36,42 @@ export class SessionState { return tenant; } - constructor(private localizationService: LocalizationService) {} + @Selector() + static getSessionDetail({ sessionDetail }: Session.State): Session.SessionDetail { + return sessionDetail; + } + + constructor( + private localizationService: LocalizationService, + private oAuthService: OAuthService, + private store: Store, + private actions: Actions, + ) { + actions + .pipe(ofActionSuccessful(GetAppConfiguration)) + .pipe(take(1)) + .subscribe(() => { + const { sessionDetail } = this.store.selectSnapshot(SessionState) || { sessionDetail: {} }; + + const fiveMinutesBefore = new Date().valueOf() - 5 * 60 * 1000; + + if ( + sessionDetail.lastExitTime && + sessionDetail.openedTabCount === 0 && + this.oAuthService.hasValidAccessToken() && + sessionDetail.remember === false && + sessionDetail.lastExitTime < fiveMinutesBefore + ) { + this.oAuthService.logOut(); + } + + this.store.dispatch(new ModifyOpenedTabCount('increase')); + + fromEvent(window, 'beforeunload').subscribe(() => { + this.store.dispatch(new ModifyOpenedTabCount('decrease')); + }); + }); + } @Action(SetLanguage) setLanguage({ patchState, dispatch }: StateContext, { payload }: SetLanguage) { @@ -40,4 +90,48 @@ export class SessionState { tenant: payload, }); } + + @Action(SetRemember) + setRemember( + { getState, patchState }: StateContext, + { payload: remember }: SetRemember, + ) { + const { sessionDetail } = getState(); + + patchState({ + sessionDetail: { + ...sessionDetail, + remember, + }, + }); + } + + @Action(ModifyOpenedTabCount) + modifyOpenedTabCount( + { getState, patchState }: StateContext, + { operation }: ModifyOpenedTabCount, + ) { + // tslint:disable-next-line: prefer-const + let { openedTabCount, lastExitTime, ...detail } = + getState().sessionDetail || ({ openedTabCount: 0 } as Session.SessionDetail); + + if (operation === 'increase') { + openedTabCount++; + } else if (operation === 'decrease') { + openedTabCount--; + lastExitTime = new Date().valueOf(); + } + + if (!openedTabCount || openedTabCount < 0) { + openedTabCount = 0; + } + + patchState({ + sessionDetail: { + openedTabCount, + lastExitTime, + ...detail, + }, + }); + } } diff --git a/npm/ng-packs/packages/core/src/lib/tests/config.plugin.spec.ts b/npm/ng-packs/packages/core/src/lib/tests/config.plugin.spec.ts index a5bc046012..aa0e0f553f 100644 --- a/npm/ng-packs/packages/core/src/lib/tests/config.plugin.spec.ts +++ b/npm/ng-packs/packages/core/src/lib/tests/config.plugin.spec.ts @@ -10,6 +10,7 @@ import { ABP } from '../models'; import { ConfigPlugin, NGXS_CONFIG_PLUGIN_OPTIONS } from '../plugins'; import { ConfigState } from '../states'; import { addAbpRoutes } from '../utils'; +import { OAuthModule } from 'angular-oauth2-oidc'; addAbpRoutes([ { @@ -323,6 +324,7 @@ describe('ConfigPlugin', () => { service: ConfigPlugin, imports: [ CoreModule, + OAuthModule.forRoot(), NgxsModule.forRoot([]), RouterTestingModule.withRoutes([ { diff --git a/npm/ng-packs/packages/core/src/lib/tests/session-state.service.spec.ts b/npm/ng-packs/packages/core/src/lib/tests/session-state.service.spec.ts index 8bca7d1ae3..cff6f9e239 100644 --- a/npm/ng-packs/packages/core/src/lib/tests/session-state.service.spec.ts +++ b/npm/ng-packs/packages/core/src/lib/tests/session-state.service.spec.ts @@ -3,13 +3,17 @@ import { SessionStateService } from '../services/session-state.service'; import { SessionState } from '../states/session.state'; import { Store } from '@ngxs/store'; import * as SessionActions from '../actions'; +import { OAuthService } from 'angular-oauth2-oidc'; describe('SessionStateService', () => { let service: SessionStateService; let spectator: SpectatorService; let store: SpyObject; - const createService = createServiceFactory({ service: SessionStateService, mocks: [Store] }); + const createService = createServiceFactory({ + service: SessionStateService, + mocks: [Store, OAuthService], + }); beforeEach(() => { spectator = createService(); service = spectator.service; diff --git a/npm/ng-packs/packages/core/src/lib/tests/session.state.spec.ts b/npm/ng-packs/packages/core/src/lib/tests/session.state.spec.ts index 412ed8d9ab..cdf616e5d2 100644 --- a/npm/ng-packs/packages/core/src/lib/tests/session.state.spec.ts +++ b/npm/ng-packs/packages/core/src/lib/tests/session.state.spec.ts @@ -1,9 +1,11 @@ import { createServiceFactory, SpectatorService } from '@ngneat/spectator/jest'; import { Session } from '../models/session'; -import { LocalizationService } from '../services'; +import { LocalizationService, AuthService } from '../services'; import { SessionState } from '../states'; import { GetAppConfiguration } from '../actions/config.actions'; -import { of } from 'rxjs'; +import { of, Subject } from 'rxjs'; +import { Store, Actions } from '@ngxs/store'; +import { OAuthService } from 'angular-oauth2-oidc'; export class DummyClass {} @@ -18,12 +20,12 @@ describe('SessionState', () => { const createService = createServiceFactory({ service: DummyClass, - mocks: [LocalizationService], + mocks: [LocalizationService, Store, Actions, OAuthService], }); beforeEach(() => { spectator = createService(); - state = new SessionState(spectator.get(LocalizationService)); + state = new SessionState(spectator.get(LocalizationService), null, null, new Subject()); }); describe('#getLanguage', () => { diff --git a/npm/ng-packs/packages/theme-shared/src/lib/tests/confirmation.service.spec.ts b/npm/ng-packs/packages/theme-shared/src/lib/tests/confirmation.service.spec.ts index 361ec0c020..c810df9367 100644 --- a/npm/ng-packs/packages/theme-shared/src/lib/tests/confirmation.service.spec.ts +++ b/npm/ng-packs/packages/theme-shared/src/lib/tests/confirmation.service.spec.ts @@ -6,6 +6,7 @@ import { NgxsModule } from '@ngxs/store'; import { MessageService } from 'primeng/components/common/messageservice'; import { ConfirmationService } from '../services/confirmation.service'; import { ThemeSharedModule } from '../theme-shared.module'; +import { OAuthModule, OAuthService } from 'angular-oauth2-oidc'; @Component({ selector: 'abp-dummy', @@ -24,6 +25,7 @@ describe('ConfirmationService', () => { component: DummyComponent, imports: [CoreModule, ThemeSharedModule.forRoot(), NgxsModule.forRoot(), RouterTestingModule], providers: [MessageService], + mocks: [OAuthService], }); beforeEach(() => { diff --git a/npm/ng-packs/packages/theme-shared/src/lib/tests/error.handler.spec.ts b/npm/ng-packs/packages/theme-shared/src/lib/tests/error.handler.spec.ts index 88ae00a874..1be1e31d1c 100644 --- a/npm/ng-packs/packages/theme-shared/src/lib/tests/error.handler.spec.ts +++ b/npm/ng-packs/packages/theme-shared/src/lib/tests/error.handler.spec.ts @@ -9,8 +9,12 @@ import { ThemeSharedModule } from '../theme-shared.module'; import { MessageService } from 'primeng/components/common/messageservice'; import { RouterError, RouterDataResolved } from '@ngxs/router-plugin'; import { NavigationError, ResolveEnd } from '@angular/router'; +import { OAuthModule, OAuthService } from 'angular-oauth2-oidc'; -@Component({ selector: 'abp-dummy', template: 'dummy works! ' }) +@Component({ + selector: 'abp-dummy', + template: 'dummy works! ', +}) class DummyComponent { constructor(public errorHandler: ErrorHandler) {} } @@ -21,6 +25,7 @@ describe('ErrorHandler', () => { const createComponent = createRoutingFactory({ component: DummyComponent, imports: [CoreModule, ThemeSharedModule.forRoot(), NgxsModule.forRoot([])], + mocks: [OAuthService], stubsEnabled: false, routes: [ { path: '', component: DummyComponent }, @@ -38,33 +43,53 @@ describe('ErrorHandler', () => { it('should display the error component when server error occurs', () => { store.dispatch(new RestOccurError(new HttpErrorResponse({ status: 500 }))); - expect(document.querySelector('.error-template')).toHaveText(DEFAULT_ERROR_MESSAGES.defaultError500.title); - expect(document.querySelector('.error-details')).toHaveText(DEFAULT_ERROR_MESSAGES.defaultError500.details); + expect(document.querySelector('.error-template')).toHaveText( + DEFAULT_ERROR_MESSAGES.defaultError500.title, + ); + expect(document.querySelector('.error-details')).toHaveText( + DEFAULT_ERROR_MESSAGES.defaultError500.details, + ); }); it('should display the error component when authorize error occurs', () => { store.dispatch(new RestOccurError(new HttpErrorResponse({ status: 403 }))); - expect(document.querySelector('.error-template')).toHaveText(DEFAULT_ERROR_MESSAGES.defaultError403.title); - expect(document.querySelector('.error-details')).toHaveText(DEFAULT_ERROR_MESSAGES.defaultError403.details); + expect(document.querySelector('.error-template')).toHaveText( + DEFAULT_ERROR_MESSAGES.defaultError403.title, + ); + expect(document.querySelector('.error-details')).toHaveText( + DEFAULT_ERROR_MESSAGES.defaultError403.details, + ); }); it('should display the error component when unknown error occurs', () => { - store.dispatch(new RestOccurError(new HttpErrorResponse({ status: 0, statusText: 'Unknown Error' }))); - expect(document.querySelector('.error-template')).toHaveText(DEFAULT_ERROR_MESSAGES.defaultError.title); + store.dispatch( + new RestOccurError(new HttpErrorResponse({ status: 0, statusText: 'Unknown Error' })), + ); + expect(document.querySelector('.error-template')).toHaveText( + DEFAULT_ERROR_MESSAGES.defaultError.title, + ); }); it('should display the confirmation when not found error occurs', () => { store.dispatch(new RestOccurError(new HttpErrorResponse({ status: 404 }))); spectator.detectChanges(); - expect(spectator.query('.abp-confirm-summary')).toHaveText(DEFAULT_ERROR_MESSAGES.defaultError404.title); - expect(spectator.query('.abp-confirm-body')).toHaveText(DEFAULT_ERROR_MESSAGES.defaultError404.details); + expect(spectator.query('.abp-confirm-summary')).toHaveText( + DEFAULT_ERROR_MESSAGES.defaultError404.title, + ); + expect(spectator.query('.abp-confirm-body')).toHaveText( + DEFAULT_ERROR_MESSAGES.defaultError404.details, + ); }); it('should display the confirmation when default error occurs', () => { store.dispatch(new RestOccurError(new HttpErrorResponse({ status: 412 }))); spectator.detectChanges(); - expect(spectator.query('.abp-confirm-summary')).toHaveText(DEFAULT_ERROR_MESSAGES.defaultError.title); - expect(spectator.query('.abp-confirm-body')).toHaveText(DEFAULT_ERROR_MESSAGES.defaultError.details); + expect(spectator.query('.abp-confirm-summary')).toHaveText( + DEFAULT_ERROR_MESSAGES.defaultError.title, + ); + expect(spectator.query('.abp-confirm-body')).toHaveText( + DEFAULT_ERROR_MESSAGES.defaultError.details, + ); }); it('should display the confirmation when authenticated error occurs', async () => { @@ -110,7 +135,8 @@ describe('ErrorHandler', () => { @Component({ selector: 'abp-dummy-error', - template: '

{{errorStatus}}

', + template: + '

{{errorStatus}}

', }) class DummyErrorComponent { errorStatus; @@ -130,12 +156,16 @@ describe('ErrorHandler with custom error component', () => { imports: [ CoreModule, ThemeSharedModule.forRoot({ - httpErrorConfig: { errorScreen: { component: DummyErrorComponent, forWhichErrors: [401, 403, 404, 500] } }, + httpErrorConfig: { + errorScreen: { component: DummyErrorComponent, forWhichErrors: [401, 403, 404, 500] }, + }, }), NgxsModule.forRoot([]), ErrorModule, ], + mocks: [OAuthService], stubsEnabled: false, + routes: [ { path: '', component: DummyComponent }, { path: 'account/login', component: RouterOutletComponent }, diff --git a/npm/ng-packs/packages/theme-shared/src/lib/tests/toaster.service.spec.ts b/npm/ng-packs/packages/theme-shared/src/lib/tests/toaster.service.spec.ts index 149ecd1fad..93272e4d30 100644 --- a/npm/ng-packs/packages/theme-shared/src/lib/tests/toaster.service.spec.ts +++ b/npm/ng-packs/packages/theme-shared/src/lib/tests/toaster.service.spec.ts @@ -6,6 +6,7 @@ import { NgxsModule } from '@ngxs/store'; import { MessageService } from 'primeng/components/common/messageservice'; import { ToasterService } from '../services/toaster.service'; import { ThemeSharedModule } from '../theme-shared.module'; +import { OAuthService } from 'angular-oauth2-oidc'; @Component({ selector: 'abp-dummy', @@ -24,6 +25,7 @@ describe('ToasterService', () => { component: DummyComponent, imports: [CoreModule, ThemeSharedModule.forRoot(), NgxsModule.forRoot(), RouterTestingModule], providers: [MessageService], + mocks: [OAuthService], }); beforeEach(() => { @@ -67,10 +69,9 @@ describe('ToasterService', () => { { summary: 'summary2', detail: 'detail2' }, ]); spectator.detectChanges(); - expect(spectator.queryAll('div.ui-toast-summary').map(node => node.textContent.trim())).toEqual([ - 'summary1', - 'summary2', - ]); + expect( + spectator.queryAll('div.ui-toast-summary').map(node => node.textContent.trim()), + ).toEqual(['summary1', 'summary2']); expect(spectator.queryAll('div.ui-toast-detail').map(node => node.textContent.trim())).toEqual([ 'detail1', 'detail2',