From 638793ca5e438d226f8fd2666c157f855ffc087c Mon Sep 17 00:00:00 2001 From: Kristof De Langhe Date: Fri, 10 Apr 2020 17:41:39 +0200 Subject: [PATCH 01/10] 70373: Login as EPerson intermediate commit --- resources/i18n/en.json5 | 8 ++++ .../eperson-form/eperson-form.component.html | 12 +++++ .../eperson-form/eperson-form.component.ts | 46 ++++++++++++++++++- src/app/core/auth/auth.interceptor.ts | 12 ++++- src/app/core/auth/auth.service.ts | 43 ++++++++++++++++- src/app/shared/form/form.component.html | 2 + 6 files changed, 118 insertions(+), 5 deletions(-) diff --git a/resources/i18n/en.json5 b/resources/i18n/en.json5 index ae3176d8b1..7f9c41c366 100644 --- a/resources/i18n/en.json5 +++ b/resources/i18n/en.json5 @@ -170,6 +170,14 @@ + "admin.access-control.epeople.actions.delete": "Delete EPerson", + + "admin.access-control.epeople.actions.impersonate": "Impersonate EPerson", + + "admin.access-control.epeople.actions.reset": "Reset password", + + "admin.access-control.epeople.actions.stop-impersonating": "Stop impersonating EPerson", + "admin.access-control.epeople.title": "DSpace Angular :: EPeople", "admin.access-control.epeople.head": "EPeople", diff --git a/src/app/+admin/admin-access-control/epeople-registry/eperson-form/eperson-form.component.html b/src/app/+admin/admin-access-control/epeople-registry/eperson-form/eperson-form.component.html index 578862b561..b87b3e0848 100644 --- a/src/app/+admin/admin-access-control/epeople-registry/eperson-form/eperson-form.component.html +++ b/src/app/+admin/admin-access-control/epeople-registry/eperson-form/eperson-form.component.html @@ -14,6 +14,18 @@ [formLayout]="formLayout" (cancel)="onCancel()" (submitForm)="onSubmit()"> + + + +
diff --git a/src/app/+admin/admin-access-control/epeople-registry/eperson-form/eperson-form.component.ts b/src/app/+admin/admin-access-control/epeople-registry/eperson-form/eperson-form.component.ts index cbcaef78dc..f886514ded 100644 --- a/src/app/+admin/admin-access-control/epeople-registry/eperson-form/eperson-form.component.ts +++ b/src/app/+admin/admin-access-control/epeople-registry/eperson-form/eperson-form.component.ts @@ -7,7 +7,7 @@ import { DynamicInputModel } from '@ng-dynamic-forms/core'; import { TranslateService } from '@ngx-translate/core'; -import { Subscription, combineLatest } from 'rxjs'; +import { Subscription, combineLatest, of } from 'rxjs'; import { Observable } from 'rxjs/internal/Observable'; import { take } from 'rxjs/operators'; import { RestResponse } from '../../../../core/cache/response.models'; @@ -22,6 +22,7 @@ import { hasValue } from '../../../../shared/empty.util'; import { FormBuilderService } from '../../../../shared/form/builder/form-builder.service'; import { NotificationsService } from '../../../../shared/notifications/notifications.service'; import { PaginationComponentOptions } from '../../../../shared/pagination/pagination-component-options.model'; +import { AuthService } from '../../../../core/auth/auth.service'; @Component({ selector: 'ds-eperson-form', @@ -105,6 +106,24 @@ export class EPersonFormComponent implements OnInit, OnDestroy { */ @Output() cancelForm: EventEmitter = new EventEmitter(); + /** + * Observable whether or not the admin is allowed to reset the EPerson's password + * TODO: Initialize the observable once the REST API supports this (currently hardcoded to return false) + */ + canReset$: Observable = of(false); + + /** + * Observable whether or not the admin is allowed to delete the EPerson + * TODO: Initialize the observable once the REST API supports this (currently hardcoded to return false) + */ + canDelete$: Observable = of(false); + + /** + * Observable whether or not the admin is allowed to impersonate the EPerson + * TODO: Initialize the observable once the REST API supports this (currently hardcoded to return true) + */ + canImpersonate$: Observable = of(true); + /** * List of subscriptions */ @@ -129,13 +148,20 @@ export class EPersonFormComponent implements OnInit, OnDestroy { */ epersonInitial: EPerson; + /** + * Whether or not this EPerson is currently being impersonated + */ + isImpersonated = false; + constructor(public epersonService: EPersonDataService, public groupsDataService: GroupDataService, private formBuilderService: FormBuilderService, private translateService: TranslateService, - private notificationsService: NotificationsService,) { + private notificationsService: NotificationsService, + private authService: AuthService) { this.subs.push(this.epersonService.getActiveEPerson().subscribe((eperson: EPerson) => { this.epersonInitial = eperson; + this.isImpersonated = this.authService.isImpersonatingUser(eperson.id); })); } @@ -364,6 +390,22 @@ export class EPersonFormComponent implements OnInit, OnDestroy { })); } + /** + * Start impersonating the EPerson + */ + impersonate() { + this.authService.impersonate(this.epersonInitial.id); + this.isImpersonated = true; + } + + /** + * Stop impersonating the EPerson + */ + stopImpersonating() { + this.authService.stopImpersonating(); + this.isImpersonated = false; + } + /** * Cancel the current edit when component is destroyed & unsub all subscriptions */ diff --git a/src/app/core/auth/auth.interceptor.ts b/src/app/core/auth/auth.interceptor.ts index 6d609a4ea3..bf11d00ccd 100644 --- a/src/app/core/auth/auth.interceptor.ts +++ b/src/app/core/auth/auth.interceptor.ts @@ -18,7 +18,7 @@ import { AppState } from '../../app.reducer'; import { AuthService } from './auth.service'; import { AuthStatus } from './models/auth-status.model'; import { AuthTokenInfo } from './models/auth-token-info.model'; -import { isNotEmpty, isNotNull, isUndefined } from '../../shared/empty.util'; +import { hasValue, isNotEmpty, isNotNull, isUndefined } from '../../shared/empty.util'; import { RedirectWhenTokenExpiredAction, RefreshTokenAction } from './auth.actions'; import { Store } from '@ngrx/store'; import { Router } from '@angular/router'; @@ -235,8 +235,16 @@ export class AuthInterceptor implements HttpInterceptor { }); // Get the auth header from the service. authorization = authService.buildAuthHeader(token); + let newHeaders = req.headers.set('authorization', authorization); + + // When present, add the ID of the EPerson we're impersonating to the headers + const impersonatingID = authService.getImpersonateID(); + if (hasValue(impersonatingID)) { + newHeaders = newHeaders.set('X-On-Behalf-Of', impersonatingID); + } + // Clone the request to add the new header. - newReq = req.clone({ headers: req.headers.set('authorization', authorization) }); + newReq = req.clone({ headers: newHeaders }); } else { newReq = req.clone(); } diff --git a/src/app/core/auth/auth.service.ts b/src/app/core/auth/auth.service.ts index 0f5c06bbc9..46d02a03cf 100644 --- a/src/app/core/auth/auth.service.ts +++ b/src/app/core/auth/auth.service.ts @@ -14,7 +14,7 @@ import { AuthRequestService } from './auth-request.service'; import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service'; import { AuthStatus } from './models/auth-status.model'; import { AuthTokenInfo, TOKENITEM } from './models/auth-token-info.model'; -import { isEmpty, isNotEmpty, isNotNull, isNotUndefined } from '../../shared/empty.util'; +import { hasValue, isEmpty, isNotEmpty, isNotNull, isNotUndefined } from '../../shared/empty.util'; import { CookieService } from '../services/cookie.service'; import { getAuthenticationToken, getRedirectUrl, isAuthenticated, isTokenRefreshing } from './selectors'; import { AppState, routerStateSelector } from '../../app.reducer'; @@ -33,6 +33,7 @@ import { AuthMethod } from './models/auth.method'; export const LOGIN_ROUTE = '/login'; export const LOGOUT_ROUTE = '/logout'; export const REDIRECT_COOKIE = 'dsRedirectUrl'; +export const IMPERSONATING_COOKIE = 'dsImpersonatingEPerson'; /** * The auth service. @@ -469,4 +470,44 @@ export class AuthService { this.storage.remove(REDIRECT_COOKIE); } + /** + * Start impersonating EPerson + * @param epersonId ID of the EPerson to impersonate + */ + impersonate(epersonId: string) { + this.storage.set(IMPERSONATING_COOKIE, epersonId); + this.refreshAfterLogout(); + } + + /** + * Stop impersonating EPerson + */ + stopImpersonating() { + this.storage.remove(IMPERSONATING_COOKIE); + this.refreshAfterLogout(); + } + + /** + * Get the ID of the EPerson we're currently impersonating + * Returns undefined if we're not impersonating anyone + */ + getImpersonateID(): string { + return this.storage.get(IMPERSONATING_COOKIE); + } + + /** + * Whether or not we are currently impersonating an EPerson + */ + isImpersonating(): boolean { + return hasValue(this.getImpersonateID()); + } + + /** + * Whether or not we are currently impersonating a specific EPerson + * @param epersonId ID of the EPerson to check + */ + isImpersonatingUser(epersonId: string): boolean { + return this.getImpersonateID() === epersonId; + } + } diff --git a/src/app/shared/form/form.component.html b/src/app/shared/form/form.component.html index 24948680c7..20fb942380 100644 --- a/src/app/shared/form/form.component.html +++ b/src/app/shared/form/form.component.html @@ -45,6 +45,8 @@ + +

From e43aa15a707800d8ca5d28c5a9f86c061ce32e18 Mon Sep 17 00:00:00 2001 From: Kristof De Langhe Date: Wed, 15 Apr 2020 11:14:41 +0200 Subject: [PATCH 02/10] 70373: Store authenticated EPerson ID in store instead of object --- src/app/core/auth/auth.actions.ts | 6 +-- src/app/core/auth/auth.effects.ts | 12 +++++- src/app/core/auth/auth.reducer.ts | 11 +++--- src/app/core/auth/auth.service.ts | 37 +++++++++++++++++-- src/app/core/auth/selectors.ts | 16 ++++---- .../profile-page/profile-page.component.ts | 11 ++---- .../auth-nav-menu/auth-nav-menu.component.ts | 9 +++-- .../user-menu/user-menu.component.ts | 8 ++-- 8 files changed, 72 insertions(+), 38 deletions(-) diff --git a/src/app/core/auth/auth.actions.ts b/src/app/core/auth/auth.actions.ts index 2c2224e878..9237c30db9 100644 --- a/src/app/core/auth/auth.actions.ts +++ b/src/app/core/auth/auth.actions.ts @@ -402,10 +402,10 @@ export class RetrieveAuthenticatedEpersonAction implements Action { */ export class RetrieveAuthenticatedEpersonSuccessAction implements Action { public type: string = AuthActionTypes.RETRIEVE_AUTHENTICATED_EPERSON_SUCCESS; - payload: EPerson; + payload: string; - constructor(user: EPerson) { - this.payload = user ; + constructor(userId: string) { + this.payload = userId ; } } diff --git a/src/app/core/auth/auth.effects.ts b/src/app/core/auth/auth.effects.ts index d153748fb9..35d5e7b043 100644 --- a/src/app/core/auth/auth.effects.ts +++ b/src/app/core/auth/auth.effects.ts @@ -43,6 +43,7 @@ import { RetrieveAuthMethodsSuccessAction, RetrieveTokenAction } from './auth.actions'; +import { hasValue } from '../../shared/empty.util'; @Injectable() export class AuthEffects { @@ -97,8 +98,15 @@ export class AuthEffects { public retrieveAuthenticatedEperson$: Observable = this.actions$.pipe( ofType(AuthActionTypes.RETRIEVE_AUTHENTICATED_EPERSON), switchMap((action: RetrieveAuthenticatedEpersonAction) => { - return this.authService.retrieveAuthenticatedUserByHref(action.payload).pipe( - map((user: EPerson) => new RetrieveAuthenticatedEpersonSuccessAction(user)), + const impersonatedUserID = this.authService.getImpersonateID(); + let user$: Observable; + if (hasValue(impersonatedUserID)) { + user$ = this.authService.retrieveAuthenticatedUserById(impersonatedUserID); + } else { + user$ = this.authService.retrieveAuthenticatedUserByHref(action.payload); + } + return user$.pipe( + map((user: EPerson) => new RetrieveAuthenticatedEpersonSuccessAction(user.id)), catchError((error) => observableOf(new RetrieveAuthenticatedEpersonErrorAction(error)))); }) ); diff --git a/src/app/core/auth/auth.reducer.ts b/src/app/core/auth/auth.reducer.ts index 19fd162d3f..16990b35a8 100644 --- a/src/app/core/auth/auth.reducer.ts +++ b/src/app/core/auth/auth.reducer.ts @@ -14,7 +14,6 @@ import { SetRedirectUrlAction } from './auth.actions'; // import models -import { EPerson } from '../eperson/models/eperson.model'; import { AuthTokenInfo } from './models/auth-token-info.model'; import { AuthMethod } from './models/auth.method'; import { AuthMethodType } from './models/auth.method-type'; @@ -49,8 +48,8 @@ export interface AuthState { // true when refreshing token refreshing?: boolean; - // the authenticated user - user?: EPerson; + // the authenticated user's id + userId?: string; // all authentication Methods enabled at the backend authMethods?: AuthMethod[]; @@ -112,7 +111,7 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut error: undefined, loading: false, info: undefined, - user: (action as RetrieveAuthenticatedEpersonSuccessAction).payload + userId: (action as RetrieveAuthenticatedEpersonSuccessAction).payload }); case AuthActionTypes.AUTHENTICATE_ERROR: @@ -144,7 +143,7 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut loading: false, info: undefined, refreshing: false, - user: undefined + userId: undefined }); case AuthActionTypes.REDIRECT_AUTHENTICATION_REQUIRED: @@ -155,7 +154,7 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut loaded: false, loading: false, info: (action as RedirectWhenTokenExpiredAction as RedirectWhenAuthenticationIsRequiredAction).payload, - user: undefined + userId: undefined }); case AuthActionTypes.REGISTRATION: diff --git a/src/app/core/auth/auth.service.ts b/src/app/core/auth/auth.service.ts index 46d02a03cf..7d642e60d2 100644 --- a/src/app/core/auth/auth.service.ts +++ b/src/app/core/auth/auth.service.ts @@ -4,7 +4,7 @@ import { HttpHeaders } from '@angular/common/http'; import { REQUEST, RESPONSE } from '@nguniversal/express-engine/tokens'; import { Observable, of as observableOf } from 'rxjs'; -import { distinctUntilChanged, filter, map, startWith, take, withLatestFrom } from 'rxjs/operators'; +import { distinctUntilChanged, filter, map, startWith, switchMap, take, withLatestFrom } from 'rxjs/operators'; import { RouterReducerState } from '@ngrx/router-store'; import { select, Store } from '@ngrx/store'; import { CookieAttributes } from 'js-cookie'; @@ -14,9 +14,15 @@ import { AuthRequestService } from './auth-request.service'; import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service'; import { AuthStatus } from './models/auth-status.model'; import { AuthTokenInfo, TOKENITEM } from './models/auth-token-info.model'; -import { hasValue, isEmpty, isNotEmpty, isNotNull, isNotUndefined } from '../../shared/empty.util'; +import { hasValue, hasValueOperator, isEmpty, isNotEmpty, isNotNull, isNotUndefined } from '../../shared/empty.util'; import { CookieService } from '../services/cookie.service'; -import { getAuthenticationToken, getRedirectUrl, isAuthenticated, isTokenRefreshing } from './selectors'; +import { + getAuthenticatedUserId, + getAuthenticationToken, + getRedirectUrl, + isAuthenticated, + isTokenRefreshing +} from './selectors'; import { AppState, routerStateSelector } from '../../app.reducer'; import { CheckAuthenticationTokenAction, @@ -164,7 +170,7 @@ export class AuthService { } /** - * Returns the authenticated user + * Returns the authenticated user by href * @returns {User} */ public retrieveAuthenticatedUserByHref(userHref: string): Observable { @@ -173,6 +179,29 @@ export class AuthService { ) } + /** + * Returns the authenticated user by id + * @returns {User} + */ + public retrieveAuthenticatedUserById(userId: string): Observable { + return this.epersonService.findById(userId).pipe( + getAllSucceededRemoteDataPayload() + ) + } + + /** + * Returns the authenticated user from the store + * @returns {User} + */ + public getAuthenticatedUserFromStore(): Observable { + return this.store.pipe( + select(getAuthenticatedUserId), + hasValueOperator(), + switchMap((id: string) => this.epersonService.findById(id)), + getAllSucceededRemoteDataPayload() + ) + } + /** * Checks if token is present into browser storage and is valid. */ diff --git a/src/app/core/auth/selectors.ts b/src/app/core/auth/selectors.ts index 4e51bc1fc9..173f82e810 100644 --- a/src/app/core/auth/selectors.ts +++ b/src/app/core/auth/selectors.ts @@ -8,7 +8,6 @@ import { createSelector } from '@ngrx/store'; */ import { AuthState } from './auth.reducer'; import { AppState } from '../../app.reducer'; -import { EPerson } from '../eperson/models/eperson.model'; /** * Returns the user state. @@ -36,12 +35,11 @@ const _isAuthenticatedLoaded = (state: AuthState) => state.loaded; /** * Return the users state - * NOTE: when state is REHYDRATED user object lose prototype so return always a new EPerson object - * @function _getAuthenticatedUser + * @function _getAuthenticatedUserId * @param {State} state - * @returns {EPerson} + * @returns {string} User ID */ -const _getAuthenticatedUser = (state: AuthState) => Object.assign(new EPerson(), state.user); +const _getAuthenticatedUserId = (state: AuthState) => state.userId; /** * Returns the authentication error. @@ -119,13 +117,13 @@ const _getAuthenticationMethods = (state: AuthState) => state.authMethods; export const getAuthenticationMethods = createSelector(getAuthState, _getAuthenticationMethods); /** - * Returns the authenticated user - * @function getAuthenticatedUser + * Returns the authenticated user id + * @function getAuthenticatedUserId * @param {AuthState} state * @param {any} props - * @return {User} + * @return {string} User ID */ -export const getAuthenticatedUser = createSelector(getAuthState, _getAuthenticatedUser); +export const getAuthenticatedUserId = createSelector(getAuthState, _getAuthenticatedUserId); /** * Returns the authentication error. diff --git a/src/app/profile-page/profile-page.component.ts b/src/app/profile-page/profile-page.component.ts index 5a2736593a..8f4cd492a2 100644 --- a/src/app/profile-page/profile-page.component.ts +++ b/src/app/profile-page/profile-page.component.ts @@ -1,9 +1,6 @@ import { Component, OnInit, ViewChild } from '@angular/core'; import { Observable } from 'rxjs/internal/Observable'; import { EPerson } from '../core/eperson/models/eperson.model'; -import { select, Store } from '@ngrx/store'; -import { getAuthenticatedUser } from '../core/auth/selectors'; -import { AppState } from '../app.reducer'; import { ProfilePageMetadataFormComponent } from './profile-page-metadata-form/profile-page-metadata-form.component'; import { ProfilePageSecurityFormComponent } from './profile-page-security-form/profile-page-security-form.component'; import { NotificationsService } from '../shared/notifications/notifications.service'; @@ -13,9 +10,10 @@ import { RemoteData } from '../core/data/remote-data'; import { PaginatedList } from '../core/data/paginated-list'; import { filter, switchMap, tap } from 'rxjs/operators'; import { EPersonDataService } from '../core/eperson/eperson-data.service'; -import { getAllSucceededRemoteData, getRemoteDataPayload, getSucceededRemoteData } from '../core/shared/operators'; +import { getAllSucceededRemoteData, getRemoteDataPayload } from '../core/shared/operators'; import { hasValue } from '../shared/empty.util'; import { followLink } from '../shared/utils/follow-link-config.model'; +import { AuthService } from '../core/auth/auth.service'; @Component({ selector: 'ds-profile-page', @@ -50,15 +48,14 @@ export class ProfilePageComponent implements OnInit { */ NOTIFICATIONS_PREFIX = 'profile.notifications.'; - constructor(private store: Store, + constructor(private authService: AuthService, private notificationsService: NotificationsService, private translate: TranslateService, private epersonService: EPersonDataService) { } ngOnInit(): void { - this.user$ = this.store.pipe( - select(getAuthenticatedUser), + this.user$ = this.authService.getAuthenticatedUserFromStore().pipe( filter((user: EPerson) => hasValue(user.id)), switchMap((user: EPerson) => this.epersonService.findById(user.id, followLink('groups'))), getAllSucceededRemoteData(), diff --git a/src/app/shared/auth-nav-menu/auth-nav-menu.component.ts b/src/app/shared/auth-nav-menu/auth-nav-menu.component.ts index 1b39ad15d9..37a45e1b20 100644 --- a/src/app/shared/auth-nav-menu/auth-nav-menu.component.ts +++ b/src/app/shared/auth-nav-menu/auth-nav-menu.component.ts @@ -9,9 +9,9 @@ import { fadeInOut, fadeOut } from '../animations/fade'; import { HostWindowService } from '../host-window.service'; import { AppState, routerStateSelector } from '../../app.reducer'; import { isNotUndefined } from '../empty.util'; -import { getAuthenticatedUser, isAuthenticated, isAuthenticationLoading } from '../../core/auth/selectors'; +import { isAuthenticated, isAuthenticationLoading } from '../../core/auth/selectors'; import { EPerson } from '../../core/eperson/models/eperson.model'; -import { LOGIN_ROUTE, LOGOUT_ROUTE } from '../../core/auth/auth.service'; +import { AuthService, LOGIN_ROUTE, LOGOUT_ROUTE } from '../../core/auth/auth.service'; @Component({ selector: 'ds-auth-nav-menu', @@ -41,7 +41,8 @@ export class AuthNavMenuComponent implements OnInit { public sub: Subscription; constructor(private store: Store, - private windowService: HostWindowService + private windowService: HostWindowService, + private authService: AuthService ) { this.isXsOrSm$ = this.windowService.isXsOrSm(); } @@ -53,7 +54,7 @@ export class AuthNavMenuComponent implements OnInit { // set loading this.loading = this.store.pipe(select(isAuthenticationLoading)); - this.user = this.store.pipe(select(getAuthenticatedUser)); + this.user = this.authService.getAuthenticatedUserFromStore(); this.showAuth = this.store.pipe( select(routerStateSelector), diff --git a/src/app/shared/auth-nav-menu/user-menu/user-menu.component.ts b/src/app/shared/auth-nav-menu/user-menu/user-menu.component.ts index 2d57a837c7..81d9b3d555 100644 --- a/src/app/shared/auth-nav-menu/user-menu/user-menu.component.ts +++ b/src/app/shared/auth-nav-menu/user-menu/user-menu.component.ts @@ -5,9 +5,10 @@ import { select, Store } from '@ngrx/store'; import { EPerson } from '../../../core/eperson/models/eperson.model'; import { AppState } from '../../../app.reducer'; -import { getAuthenticatedUser, isAuthenticationLoading } from '../../../core/auth/selectors'; +import { isAuthenticationLoading } from '../../../core/auth/selectors'; import { MYDSPACE_ROUTE } from '../../../+my-dspace-page/my-dspace-page.component'; import { getProfileModulePath } from '../../../app-routing.module'; +import { AuthService } from '../../../core/auth/auth.service'; /** * This component represents the user nav menu. @@ -42,7 +43,8 @@ export class UserMenuComponent implements OnInit { */ public profileRoute = getProfileModulePath(); - constructor(private store: Store) { + constructor(private store: Store, + private authService: AuthService) { } /** @@ -54,7 +56,7 @@ export class UserMenuComponent implements OnInit { this.loading$ = this.store.pipe(select(isAuthenticationLoading)); // set user - this.user$ = this.store.pipe(select(getAuthenticatedUser)); + this.user$ = this.authService.getAuthenticatedUserFromStore(); } } From c48bb2cbb0ac8f02f92ce98bd2fbb6a3d2cf4213 Mon Sep 17 00:00:00 2001 From: Kristof De Langhe Date: Wed, 15 Apr 2020 13:08:04 +0200 Subject: [PATCH 03/10] 70373: Stop impersonating button in navbar + clear cookie on logout --- resources/i18n/en.json5 | 2 + .../eperson-form/eperson-form.component.ts | 2 +- src/app/core/auth/auth.effects.ts | 1 + src/app/core/auth/auth.service.ts | 7 ++++ src/app/header/header.component.html | 1 + .../impersonate-navbar.component.html | 7 ++++ .../impersonate-navbar.component.spec.ts | 0 .../impersonate-navbar.component.ts | 42 +++++++++++++++++++ src/app/shared/shared.module.ts | 4 +- 9 files changed, 64 insertions(+), 2 deletions(-) create mode 100644 src/app/shared/impersonate-navbar/impersonate-navbar.component.html create mode 100644 src/app/shared/impersonate-navbar/impersonate-navbar.component.spec.ts create mode 100644 src/app/shared/impersonate-navbar/impersonate-navbar.component.ts diff --git a/resources/i18n/en.json5 b/resources/i18n/en.json5 index 7f9c41c366..d41f314325 100644 --- a/resources/i18n/en.json5 +++ b/resources/i18n/en.json5 @@ -1754,6 +1754,8 @@ "nav.statistics.header": "Statistics", + "nav.stop-impersonating": "Stop impersonating EPerson", + "orgunit.listelement.badge": "Organizational Unit", diff --git a/src/app/+admin/admin-access-control/epeople-registry/eperson-form/eperson-form.component.ts b/src/app/+admin/admin-access-control/epeople-registry/eperson-form/eperson-form.component.ts index f886514ded..f221042418 100644 --- a/src/app/+admin/admin-access-control/epeople-registry/eperson-form/eperson-form.component.ts +++ b/src/app/+admin/admin-access-control/epeople-registry/eperson-form/eperson-form.component.ts @@ -402,7 +402,7 @@ export class EPersonFormComponent implements OnInit, OnDestroy { * Stop impersonating the EPerson */ stopImpersonating() { - this.authService.stopImpersonating(); + this.authService.stopImpersonatingAndRefresh(); this.isImpersonated = false; } diff --git a/src/app/core/auth/auth.effects.ts b/src/app/core/auth/auth.effects.ts index 35d5e7b043..717aaff01e 100644 --- a/src/app/core/auth/auth.effects.ts +++ b/src/app/core/auth/auth.effects.ts @@ -201,6 +201,7 @@ export class AuthEffects { .pipe( ofType(AuthActionTypes.LOG_OUT), switchMap(() => { + this.authService.stopImpersonating(); return this.authService.logout().pipe( map((value) => new LogOutSuccessAction()), catchError((error) => observableOf(new LogOutErrorAction(error))) diff --git a/src/app/core/auth/auth.service.ts b/src/app/core/auth/auth.service.ts index 7d642e60d2..ce0fafe277 100644 --- a/src/app/core/auth/auth.service.ts +++ b/src/app/core/auth/auth.service.ts @@ -513,6 +513,13 @@ export class AuthService { */ stopImpersonating() { this.storage.remove(IMPERSONATING_COOKIE); + } + + /** + * Stop impersonating EPerson and refresh the store/ui + */ + stopImpersonatingAndRefresh() { + this.stopImpersonating(); this.refreshAfterLogout(); } diff --git a/src/app/header/header.component.html b/src/app/header/header.component.html index 58f7cb1ecf..5ce0cdb410 100644 --- a/src/app/header/header.component.html +++ b/src/app/header/header.component.html @@ -8,6 +8,7 @@ +
+ + diff --git a/src/app/shared/impersonate-navbar/impersonate-navbar.component.spec.ts b/src/app/shared/impersonate-navbar/impersonate-navbar.component.spec.ts new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/shared/impersonate-navbar/impersonate-navbar.component.ts b/src/app/shared/impersonate-navbar/impersonate-navbar.component.ts new file mode 100644 index 0000000000..19293566ef --- /dev/null +++ b/src/app/shared/impersonate-navbar/impersonate-navbar.component.ts @@ -0,0 +1,42 @@ +import { Component, OnInit } from '@angular/core'; +import { select, Store } from '@ngrx/store'; +import { AppState } from '../../app.reducer'; +import { AuthService } from '../../core/auth/auth.service'; +import { Observable } from 'rxjs/internal/Observable'; +import { isAuthenticated } from '../../core/auth/selectors'; + +@Component({ + selector: 'ds-impersonate-navbar', + templateUrl: 'impersonate-navbar.component.html' +}) +/** + * Navbar component for actions to take concerning impersonating users + */ +export class ImpersonateNavbarComponent implements OnInit { + /** + * Whether or not the user is authenticated. + * @type {Observable} + */ + isAuthenticated$: Observable; + + /** + * Is the user currently impersonating another user? + */ + isImpersonating: boolean; + + constructor(private store: Store, + private authService: AuthService) { + } + + ngOnInit(): void { + this.isAuthenticated$ = this.store.pipe(select(isAuthenticated)); + this.isImpersonating = this.authService.isImpersonating(); + } + + /** + * Stop impersonating the user + */ + stopImpersonating() { + this.authService.stopImpersonatingAndRefresh(); + } +} diff --git a/src/app/shared/shared.module.ts b/src/app/shared/shared.module.ts index a136b7826c..ccf4b6d223 100644 --- a/src/app/shared/shared.module.ts +++ b/src/app/shared/shared.module.ts @@ -191,6 +191,7 @@ import { ItemVersionsNoticeComponent } from './item/item-versions/notice/item-ve import { ClaimedTaskActionsLoaderComponent } from './mydspace-actions/claimed-task/switcher/claimed-task-actions-loader.component'; import { ClaimedTaskActionsDirective } from './mydspace-actions/claimed-task/switcher/claimed-task-actions.directive'; import { ClaimedTaskActionsEditMetadataComponent } from './mydspace-actions/claimed-task/edit-metadata/claimed-task-actions-edit-metadata.component'; +import { ImpersonateNavbarComponent } from './impersonate-navbar/impersonate-navbar.component'; const MODULES = [ // Do NOT include UniversalModule, HttpModule, or JsonpModule here @@ -367,7 +368,8 @@ const COMPONENTS = [ LogInContainerComponent, ItemVersionsComponent, PublicationSearchResultListElementComponent, - ItemVersionsNoticeComponent + ItemVersionsNoticeComponent, + ImpersonateNavbarComponent ]; const ENTRY_COMPONENTS = [ From 4cd11bcbcafe2433476cc9c1bf78eac74a16cb26 Mon Sep 17 00:00:00 2001 From: Kristof De Langhe Date: Wed, 15 Apr 2020 15:35:41 +0200 Subject: [PATCH 04/10] 70373: Fix existing texts --- .../eperson-form.component.spec.ts | 3 ++ .../eperson-form/eperson-form.component.ts | 4 ++- src/app/core/auth/auth.effects.spec.ts | 2 +- src/app/core/auth/auth.reducer.spec.ts | 30 +++++++++---------- .../profile-page.component.spec.ts | 11 +++++-- .../auth-nav-menu.component.spec.ts | 29 ++++++++++-------- .../user-menu/user-menu.component.spec.ts | 21 ++++++++++--- src/app/shared/testing/auth-service-stub.ts | 30 +++++++++++++++++++ 8 files changed, 94 insertions(+), 36 deletions(-) diff --git a/src/app/+admin/admin-access-control/epeople-registry/eperson-form/eperson-form.component.spec.ts b/src/app/+admin/admin-access-control/epeople-registry/eperson-form/eperson-form.component.spec.ts index 4319c77e8b..7a301aa7e9 100644 --- a/src/app/+admin/admin-access-control/epeople-registry/eperson-form/eperson-form.component.spec.ts +++ b/src/app/+admin/admin-access-control/epeople-registry/eperson-form/eperson-form.component.spec.ts @@ -31,6 +31,8 @@ import { NotificationsServiceStub } from '../../../../shared/testing/notificatio import { createSuccessfulRemoteDataObject$ } from '../../../../shared/testing/utils'; import { EPeopleRegistryComponent } from '../epeople-registry.component'; import { EPersonFormComponent } from './eperson-form.component'; +import { AuthService } from '../../../../core/auth/auth.service'; +import { AuthServiceStub } from '../../../../shared/testing/auth-service-stub'; describe('EPersonFormComponent', () => { let component: EPersonFormComponent; @@ -125,6 +127,7 @@ describe('EPersonFormComponent', () => { { provide: Store, useValue: {} }, { provide: RemoteDataBuildService, useValue: {} }, { provide: HALEndpointService, useValue: {} }, + { provide: AuthService, useValue: new AuthServiceStub() }, EPeopleRegistryComponent ], schemas: [NO_ERRORS_SCHEMA] diff --git a/src/app/+admin/admin-access-control/epeople-registry/eperson-form/eperson-form.component.ts b/src/app/+admin/admin-access-control/epeople-registry/eperson-form/eperson-form.component.ts index f221042418..9e3bcc88c0 100644 --- a/src/app/+admin/admin-access-control/epeople-registry/eperson-form/eperson-form.component.ts +++ b/src/app/+admin/admin-access-control/epeople-registry/eperson-form/eperson-form.component.ts @@ -161,7 +161,9 @@ export class EPersonFormComponent implements OnInit, OnDestroy { private authService: AuthService) { this.subs.push(this.epersonService.getActiveEPerson().subscribe((eperson: EPerson) => { this.epersonInitial = eperson; - this.isImpersonated = this.authService.isImpersonatingUser(eperson.id); + if (hasValue(eperson)) { + this.isImpersonated = this.authService.isImpersonatingUser(eperson.id); + } })); } diff --git a/src/app/core/auth/auth.effects.spec.ts b/src/app/core/auth/auth.effects.spec.ts index 1f6fa51afd..872c71022f 100644 --- a/src/app/core/auth/auth.effects.spec.ts +++ b/src/app/core/auth/auth.effects.spec.ts @@ -234,7 +234,7 @@ describe('AuthEffects', () => { } }); - const expected = cold('--b-', { b: new RetrieveAuthenticatedEpersonSuccessAction(EPersonMock) }); + const expected = cold('--b-', { b: new RetrieveAuthenticatedEpersonSuccessAction(EPersonMock.id) }); expect(authEffects.retrieveAuthenticatedEperson$).toBeObservable(expected); }); diff --git a/src/app/core/auth/auth.reducer.spec.ts b/src/app/core/auth/auth.reducer.spec.ts index 7a39ef3da4..a50c469836 100644 --- a/src/app/core/auth/auth.reducer.spec.ts +++ b/src/app/core/auth/auth.reducer.spec.ts @@ -189,7 +189,7 @@ describe('authReducer', () => { error: undefined, loading: false, info: undefined, - user: EPersonMock + userId: EPersonMock.id }; const action = new LogOutAction(); @@ -206,7 +206,7 @@ describe('authReducer', () => { error: undefined, loading: false, info: undefined, - user: EPersonMock + userId: EPersonMock.id }; const action = new LogOutSuccessAction(); @@ -219,7 +219,7 @@ describe('authReducer', () => { loading: false, info: undefined, refreshing: false, - user: undefined + userId: undefined }; expect(newState).toEqual(state); }); @@ -232,7 +232,7 @@ describe('authReducer', () => { error: undefined, loading: false, info: undefined, - user: EPersonMock + userId: EPersonMock.id }; const action = new LogOutErrorAction(mockError); @@ -244,7 +244,7 @@ describe('authReducer', () => { error: 'Test error message', loading: false, info: undefined, - user: EPersonMock + userId: EPersonMock.id }; expect(newState).toEqual(state); }); @@ -258,7 +258,7 @@ describe('authReducer', () => { loading: true, info: undefined }; - const action = new RetrieveAuthenticatedEpersonSuccessAction(EPersonMock); + const action = new RetrieveAuthenticatedEpersonSuccessAction(EPersonMock.id); const newState = authReducer(initialState, action); state = { authenticated: true, @@ -267,7 +267,7 @@ describe('authReducer', () => { error: undefined, loading: false, info: undefined, - user: EPersonMock + userId: EPersonMock.id }; expect(newState).toEqual(state); }); @@ -301,7 +301,7 @@ describe('authReducer', () => { error: undefined, loading: false, info: undefined, - user: EPersonMock + userId: EPersonMock.id }; const newTokenInfo = new AuthTokenInfo('Refreshed token'); const action = new RefreshTokenAction(newTokenInfo); @@ -313,7 +313,7 @@ describe('authReducer', () => { error: undefined, loading: false, info: undefined, - user: EPersonMock, + userId: EPersonMock.id, refreshing: true }; expect(newState).toEqual(state); @@ -327,7 +327,7 @@ describe('authReducer', () => { error: undefined, loading: false, info: undefined, - user: EPersonMock, + userId: EPersonMock.id, refreshing: true }; const newTokenInfo = new AuthTokenInfo('Refreshed token'); @@ -340,7 +340,7 @@ describe('authReducer', () => { error: undefined, loading: false, info: undefined, - user: EPersonMock, + userId: EPersonMock.id, refreshing: false }; expect(newState).toEqual(state); @@ -354,7 +354,7 @@ describe('authReducer', () => { error: undefined, loading: false, info: undefined, - user: EPersonMock, + userId: EPersonMock.id, refreshing: true }; const action = new RefreshTokenErrorAction(); @@ -367,7 +367,7 @@ describe('authReducer', () => { loading: false, info: undefined, refreshing: false, - user: undefined + userId: undefined }; expect(newState).toEqual(state); }); @@ -380,7 +380,7 @@ describe('authReducer', () => { error: undefined, loading: false, info: undefined, - user: EPersonMock + userId: EPersonMock.id }; state = { @@ -390,7 +390,7 @@ describe('authReducer', () => { loading: false, error: undefined, info: 'Message', - user: undefined + userId: undefined }; }); diff --git a/src/app/profile-page/profile-page.component.spec.ts b/src/app/profile-page/profile-page.component.spec.ts index 5992012be9..3b9c8e7d6d 100644 --- a/src/app/profile-page/profile-page.component.spec.ts +++ b/src/app/profile-page/profile-page.component.spec.ts @@ -12,12 +12,15 @@ import { AuthTokenInfo } from '../core/auth/models/auth-token-info.model'; import { EPersonDataService } from '../core/eperson/eperson-data.service'; import { NotificationsService } from '../shared/notifications/notifications.service'; import { authReducer } from '../core/auth/auth.reducer'; +import { of } from 'rxjs/internal/observable/of'; +import { AuthService } from '../core/auth/auth.service'; describe('ProfilePageComponent', () => { let component: ProfilePageComponent; let fixture: ComponentFixture; const user = Object.assign(new EPerson(), { + id: 'userId', groups: createSuccessfulRemoteDataObject$(createPaginatedList([])) }); const authState = { @@ -25,9 +28,12 @@ describe('ProfilePageComponent', () => { loaded: true, loading: false, authToken: new AuthTokenInfo('test_token'), - user: user + userId: user.id }; + const authService = jasmine.createSpyObj('authService', { + getAuthenticatedUserFromStore: of(user) + }); const epersonService = jasmine.createSpyObj('epersonService', { findById: createSuccessfulRemoteDataObject$(user) }); @@ -43,7 +49,8 @@ describe('ProfilePageComponent', () => { imports: [StoreModule.forRoot(authReducer), TranslateModule.forRoot(), RouterTestingModule.withRoutes([])], providers: [ { provide: EPersonDataService, useValue: epersonService }, - { provide: NotificationsService, useValue: notificationsService } + { provide: NotificationsService, useValue: notificationsService }, + { provide: AuthService, useValue: authService } ], schemas: [NO_ERRORS_SCHEMA] }).compileComponents(); diff --git a/src/app/shared/auth-nav-menu/auth-nav-menu.component.spec.ts b/src/app/shared/auth-nav-menu/auth-nav-menu.component.spec.ts index 5e01494674..9c20f2be4d 100644 --- a/src/app/shared/auth-nav-menu/auth-nav-menu.component.spec.ts +++ b/src/app/shared/auth-nav-menu/auth-nav-menu.component.spec.ts @@ -14,6 +14,7 @@ import { HostWindowService } from '../host-window.service'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { AuthTokenInfo } from '../../core/auth/models/auth-token-info.model'; import { AuthService } from '../../core/auth/auth.service'; +import { of } from 'rxjs/internal/observable/of'; describe('AuthNavMenuComponent', () => { @@ -25,9 +26,19 @@ describe('AuthNavMenuComponent', () => { let notAuthState: AuthState; let authState: AuthState; + let authService: AuthService; + let routerState = { url: '/home' }; + + function serviceInit() { + authService = jasmine.createSpyObj('authService', { + getAuthenticatedUserFromStore: of(EPersonMock), + setRedirectUrl: {} + }); + } + function init() { notAuthState = { authenticated: false, @@ -39,13 +50,14 @@ describe('AuthNavMenuComponent', () => { loaded: true, loading: false, authToken: new AuthTokenInfo('test_token'), - user: EPersonMock + userId: EPersonMock.id }; } describe('when is a not mobile view', () => { beforeEach(async(() => { const window = new HostWindowServiceStub(800); + serviceInit(); // refine the test module by declaring the test component TestBed.configureTestingModule({ @@ -59,12 +71,7 @@ describe('AuthNavMenuComponent', () => { ], providers: [ { provide: HostWindowService, useValue: window }, - { - provide: AuthService, useValue: { - setRedirectUrl: () => { /*empty*/ - } - } - } + { provide: AuthService, useValue: authService } ], schemas: [ CUSTOM_ELEMENTS_SCHEMA @@ -239,6 +246,7 @@ describe('AuthNavMenuComponent', () => { describe('when is a mobile view', () => { beforeEach(async(() => { const window = new HostWindowServiceStub(300); + serviceInit(); // refine the test module by declaring the test component TestBed.configureTestingModule({ @@ -252,12 +260,7 @@ describe('AuthNavMenuComponent', () => { ], providers: [ { provide: HostWindowService, useValue: window }, - { - provide: AuthService, useValue: { - setRedirectUrl: () => { /*empty*/ - } - } - } + { provide: AuthService, useValue: authService } ], schemas: [ CUSTOM_ELEMENTS_SCHEMA diff --git a/src/app/shared/auth-nav-menu/user-menu/user-menu.component.spec.ts b/src/app/shared/auth-nav-menu/user-menu/user-menu.component.spec.ts index 512d9e0917..80d03dfc47 100644 --- a/src/app/shared/auth-nav-menu/user-menu/user-menu.component.spec.ts +++ b/src/app/shared/auth-nav-menu/user-menu/user-menu.component.spec.ts @@ -12,6 +12,8 @@ import { AppState } from '../../../app.reducer'; import { MockTranslateLoader } from '../../mocks/mock-translate-loader'; import { cold } from 'jasmine-marbles'; import { By } from '@angular/platform-browser'; +import { AuthService } from '../../../core/auth/auth.service'; +import { of } from 'rxjs'; describe('UserMenuComponent', () => { @@ -20,6 +22,13 @@ describe('UserMenuComponent', () => { let deUserMenu: DebugElement; let authState: AuthState; let authStateLoading: AuthState; + let authService: AuthService; + + function serviceInit() { + authService = jasmine.createSpyObj('authService', { + getAuthenticatedUserFromStore: of(EPersonMock) + }); + } function init() { authState = { @@ -27,18 +36,19 @@ describe('UserMenuComponent', () => { loaded: true, loading: false, authToken: new AuthTokenInfo('test_token'), - user: EPersonMock + userId: EPersonMock.id }; authStateLoading = { authenticated: true, loaded: true, loading: true, authToken: null, - user: EPersonMock + userId: EPersonMock.id }; } beforeEach(async(() => { + serviceInit(); TestBed.configureTestingModule({ imports: [ StoreModule.forRoot(authReducer), @@ -49,6 +59,9 @@ describe('UserMenuComponent', () => { } }) ], + providers: [ + { provide: AuthService, useValue: authService } + ], declarations: [ UserMenuComponent ], @@ -93,7 +106,7 @@ describe('UserMenuComponent', () => { b: true })); - expect(component.user$).toBeObservable(cold('c', { + expect(component.user$).toBeObservable(cold('(c|)', { c: EPersonMock })); @@ -132,7 +145,7 @@ describe('UserMenuComponent', () => { b: false })); - expect(component.user$).toBeObservable(cold('c', { + expect(component.user$).toBeObservable(cold('(c|)', { c: EPersonMock })); diff --git a/src/app/shared/testing/auth-service-stub.ts b/src/app/shared/testing/auth-service-stub.ts index 26ce79cb5f..2fd401aa49 100644 --- a/src/app/shared/testing/auth-service-stub.ts +++ b/src/app/shared/testing/auth-service-stub.ts @@ -5,6 +5,7 @@ import { EPersonMock } from './eperson-mock'; import { EPerson } from '../../core/eperson/models/eperson.model'; import { createSuccessfulRemoteDataObject$ } from './utils'; import { AuthMethod } from '../../core/auth/models/auth.method'; +import { hasValue } from '../empty.util'; export const authMethodsMock = [ new AuthMethod('password'), @@ -14,6 +15,7 @@ export const authMethodsMock = [ export class AuthServiceStub { token: AuthTokenInfo = new AuthTokenInfo('token_test'); + impersonating: string; private _tokenExpired = false; private redirectUrl; @@ -47,6 +49,10 @@ export class AuthServiceStub { return observableOf(EPersonMock); } + public retrieveAuthenticatedUserById(id: string): Observable { + return observableOf(EPersonMock); + } + public buildAuthHeader(token?: AuthTokenInfo): string { return `Bearer ${token.accessToken}`; } @@ -120,4 +126,28 @@ export class AuthServiceStub { retrieveAuthMethodsFromAuthStatus(status: AuthStatus) { return observableOf(authMethodsMock); } + + impersonate(id: string) { + this.impersonating = id; + } + + isImpersonating() { + return hasValue(this.impersonating); + } + + isImpersonatingUser(id: string) { + return this.impersonating === id; + } + + stopImpersonating() { + this.impersonating = undefined; + } + + stopImpersonatingAndRefresh() { + this.stopImpersonating(); + } + + getImpersonateID() { + return this.impersonating; + } } From 7df5025aa5961097a7b5867729aac629b0654caf Mon Sep 17 00:00:00 2001 From: Kristof De Langhe Date: Wed, 15 Apr 2020 16:23:41 +0200 Subject: [PATCH 05/10] 70373: EPerson impersonate tests --- .../eperson-form.component.spec.ts | 40 +++++++++- .../impersonate-navbar.component.spec.ts | 80 +++++++++++++++++++ 2 files changed, 119 insertions(+), 1 deletion(-) diff --git a/src/app/+admin/admin-access-control/epeople-registry/eperson-form/eperson-form.component.spec.ts b/src/app/+admin/admin-access-control/epeople-registry/eperson-form/eperson-form.component.spec.ts index 7a301aa7e9..16acd14a19 100644 --- a/src/app/+admin/admin-access-control/epeople-registry/eperson-form/eperson-form.component.spec.ts +++ b/src/app/+admin/admin-access-control/epeople-registry/eperson-form/eperson-form.component.spec.ts @@ -42,6 +42,7 @@ describe('EPersonFormComponent', () => { let mockEPeople; let ePersonDataServiceStub: any; + let authService: AuthServiceStub; beforeEach(async(() => { mockEPeople = [EPersonMock, EPersonMock2]; @@ -106,6 +107,7 @@ describe('EPersonFormComponent', () => { }; builderService = getMockFormBuilderService(); translateService = getMockTranslateService(); + authService = new AuthServiceStub(); TestBed.configureTestingModule({ imports: [CommonModule, NgbModule, FormsModule, ReactiveFormsModule, BrowserModule, TranslateModule.forRoot({ @@ -127,7 +129,7 @@ describe('EPersonFormComponent', () => { { provide: Store, useValue: {} }, { provide: RemoteDataBuildService, useValue: {} }, { provide: HALEndpointService, useValue: {} }, - { provide: AuthService, useValue: new AuthServiceStub() }, + { provide: AuthService, useValue: authService }, EPeopleRegistryComponent ], schemas: [NO_ERRORS_SCHEMA] @@ -231,4 +233,40 @@ describe('EPersonFormComponent', () => { }); }); + describe('impersonate', () => { + let ePersonId; + + beforeEach(() => { + spyOn(authService, 'impersonate').and.callThrough(); + ePersonId = 'testEPersonId'; + component.epersonInitial = Object.assign(new EPerson(), { + id: ePersonId + }); + component.impersonate(); + }); + + it('should call authService.impersonate', () => { + expect(authService.impersonate).toHaveBeenCalledWith(ePersonId); + }); + + it('should set isImpersonated to true', () => { + expect(component.isImpersonated).toBe(true); + }); + }); + + describe('stopImpersonating', () => { + beforeEach(() => { + spyOn(authService, 'stopImpersonatingAndRefresh').and.callThrough(); + component.stopImpersonating(); + }); + + it('should call authService.stopImpersonatingAndRefresh', () => { + expect(authService.stopImpersonatingAndRefresh).toHaveBeenCalled(); + }); + + it('should set isImpersonated to false', () => { + expect(component.isImpersonated).toBe(false); + }); + }); + }); diff --git a/src/app/shared/impersonate-navbar/impersonate-navbar.component.spec.ts b/src/app/shared/impersonate-navbar/impersonate-navbar.component.spec.ts index e69de29bb2..16e445f183 100644 --- a/src/app/shared/impersonate-navbar/impersonate-navbar.component.spec.ts +++ b/src/app/shared/impersonate-navbar/impersonate-navbar.component.spec.ts @@ -0,0 +1,80 @@ +import { ImpersonateNavbarComponent } from './impersonate-navbar.component'; +import { async, ComponentFixture, inject, TestBed } from '@angular/core/testing'; +import { VarDirective } from '../utils/var.directive'; +import { RouterTestingModule } from '@angular/router/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { AuthService } from '../../core/auth/auth.service'; +import { Store, StoreModule } from '@ngrx/store'; +import { authReducer, AuthState } from '../../core/auth/auth.reducer'; +import { AuthTokenInfo } from '../../core/auth/models/auth-token-info.model'; +import { EPersonMock } from '../testing/eperson-mock'; +import { AppState } from '../../app.reducer'; +import { By } from '@angular/platform-browser'; + +describe('ImpersonateNavbarComponent', () => { + let component: ImpersonateNavbarComponent; + let fixture: ComponentFixture; + let authService: AuthService; + let authState: AuthState; + + beforeEach(async(() => { + authService = jasmine.createSpyObj('authService', { + isImpersonating: false, + stopImpersonatingAndRefresh: {} + }); + authState = { + authenticated: true, + loaded: true, + loading: false, + authToken: new AuthTokenInfo('test_token'), + userId: EPersonMock.id + }; + + TestBed.configureTestingModule({ + declarations: [ImpersonateNavbarComponent, VarDirective], + imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([]), StoreModule.forRoot(authReducer)], + providers: [ + { provide: AuthService, useValue: authService } + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + })); + + beforeEach(inject([Store], (store: Store) => { + store + .subscribe((state) => { + (state as any).core = Object.create({}); + (state as any).core.auth = authState; + }); + + fixture = TestBed.createComponent(ImpersonateNavbarComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + })); + + describe('when the user is impersonating another user', () => { + beforeEach(() => { + component.isImpersonating = true; + fixture.detectChanges(); + }); + + it('should display a button', () => { + const button = fixture.debugElement.query(By.css('button')); + expect(button).not.toBeNull(); + }); + + it('should call authService\'s stopImpersonatingAndRefresh upon clicking the button', () => { + const button = fixture.debugElement.query(By.css('button')).nativeElement; + button.click(); + expect(authService.stopImpersonatingAndRefresh).toHaveBeenCalled(); + }); + }); + + describe('when the user is not impersonating another user', () => { + it('should not display a button', () => { + const button = fixture.debugElement.query(By.css('button')); + expect(button).toBeNull(); + }); + }); +}); From 5e94cf379c29a9e4d358da5682479c1911ec78c3 Mon Sep 17 00:00:00 2001 From: Kristof De Langhe Date: Wed, 15 Apr 2020 16:47:18 +0200 Subject: [PATCH 06/10] 70373: AuthService impersonate tests --- src/app/core/auth/auth.service.spec.ts | 117 ++++++++++++++++++++++++- 1 file changed, 116 insertions(+), 1 deletion(-) diff --git a/src/app/core/auth/auth.service.spec.ts b/src/app/core/auth/auth.service.spec.ts index 03759987bf..c3b2dcad00 100644 --- a/src/app/core/auth/auth.service.spec.ts +++ b/src/app/core/auth/auth.service.spec.ts @@ -8,7 +8,7 @@ import { of as observableOf } from 'rxjs'; import { authReducer, AuthState } from './auth.reducer'; import { NativeWindowRef, NativeWindowService } from '../services/window.service'; -import { AuthService } from './auth.service'; +import { AuthService, IMPERSONATING_COOKIE } from './auth.service'; import { RouterStub } from '../../shared/testing/router-stub'; import { ActivatedRouteStub } from '../../shared/testing/active-router-stub'; import { CookieService } from '../services/cookie.service'; @@ -316,5 +316,120 @@ describe('AuthService test', () => { expect(routeServiceMock.getHistory).toHaveBeenCalled(); expect(routerStub.navigateByUrl).toHaveBeenCalledWith('/'); }); + + describe('impersonate', () => { + const userId = 'testUserId'; + + beforeEach(() => { + spyOn(authService, 'refreshAfterLogout'); + authService.impersonate(userId); + }); + + it('should impersonate user', () => { + expect(storage.set).toHaveBeenCalledWith(IMPERSONATING_COOKIE, userId); + }); + + it('should call refreshAfterLogout', () => { + expect(authService.refreshAfterLogout).toHaveBeenCalled(); + }); + }); + + describe('stopImpersonating', () => { + beforeEach(() => { + authService.stopImpersonating(); + }); + + it('should impersonate user', () => { + expect(storage.remove).toHaveBeenCalledWith(IMPERSONATING_COOKIE); + }); + }); + + describe('stopImpersonatingAndRefresh', () => { + beforeEach(() => { + spyOn(authService, 'refreshAfterLogout'); + authService.stopImpersonatingAndRefresh(); + }); + + it('should impersonate user', () => { + expect(storage.remove).toHaveBeenCalledWith(IMPERSONATING_COOKIE); + }); + + it('should call refreshAfterLogout', () => { + expect(authService.refreshAfterLogout).toHaveBeenCalled(); + }); + }); + + describe('getImpersonateID', () => { + beforeEach(() => { + authService.getImpersonateID(); + }); + + it('should impersonate user', () => { + expect(storage.get).toHaveBeenCalledWith(IMPERSONATING_COOKIE); + }); + }); + + describe('isImpersonating', () => { + const userId = 'testUserId'; + let result: boolean; + + describe('when the cookie doesn\'t contain a value', () => { + beforeEach(() => { + result = authService.isImpersonating(); + }); + + it('should return false', () => { + expect(result).toBe(false); + }); + }); + + describe('when the cookie contains a value', () => { + beforeEach(() => { + storage.get = jasmine.createSpy().and.returnValue(userId); + result = authService.isImpersonating(); + }); + + it('should return true', () => { + expect(result).toBe(true); + }); + }); + }); + + describe('isImpersonatingUser', () => { + const userId = 'testUserId'; + let result: boolean; + + describe('when the cookie doesn\'t contain a value', () => { + beforeEach(() => { + result = authService.isImpersonatingUser(userId); + }); + + it('should return false', () => { + expect(result).toBe(false); + }); + }); + + describe('when the cookie contains the right value', () => { + beforeEach(() => { + storage.get = jasmine.createSpy().and.returnValue(userId); + result = authService.isImpersonatingUser(userId); + }); + + it('should return true', () => { + expect(result).toBe(true); + }); + }); + + describe('when the cookie contains the wrong value', () => { + beforeEach(() => { + storage.get = jasmine.createSpy().and.returnValue('wrongValue'); + result = authService.isImpersonatingUser(userId); + }); + + it('should return false', () => { + expect(result).toBe(false); + }); + }); + }); }); }); From a6d7b6444c5ddf385b6f939b2d0f88e065cfa354 Mon Sep 17 00:00:00 2001 From: Kristof De Langhe Date: Thu, 16 Apr 2020 15:12:41 +0200 Subject: [PATCH 07/10] 70373: Remove unnecessary route navigate --- src/app/core/auth/auth.service.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/app/core/auth/auth.service.ts b/src/app/core/auth/auth.service.ts index ce0fafe277..d5f76c106b 100644 --- a/src/app/core/auth/auth.service.ts +++ b/src/app/core/auth/auth.service.ts @@ -460,7 +460,6 @@ export class AuthService { * Refresh route navigated */ public refreshAfterLogout() { - this.router.navigate(['/home']); // Hard redirect to home page, so that all state is definitely lost this._window.nativeWindow.location.href = '/home'; } From c2789dfbf7f7602a4457f03909e02b25a8f1ffda Mon Sep 17 00:00:00 2001 From: Art Lowel Date: Fri, 24 Apr 2020 13:16:52 +0200 Subject: [PATCH 08/10] fix issue where a hard refresh wouldn't work to clear the state due to the browser caching the html --- src/app/app-routing.module.ts | 1 + src/app/app.metareducers.ts | 19 ++++++++++++++++++- src/app/core/auth/auth.service.ts | 5 +++-- 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts index 258848ce83..168c828c79 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -60,6 +60,7 @@ export function getDSOPath(dso: DSpaceObject): string { imports: [ RouterModule.forRoot([ { path: '', redirectTo: '/home', pathMatch: 'full' }, + { path: 'reload/:rnd', redirectTo: '/home', pathMatch: 'full' }, { path: 'home', loadChildren: './+home-page/home-page.module#HomePageModule', data: { showBreadcrumbs: false } }, { path: 'community-list', loadChildren: './community-list-page/community-list-page.module#CommunityListPageModule' }, { path: 'id', loadChildren: './+lookup-by-id/lookup-by-id.module#LookupIdModule' }, diff --git a/src/app/app.metareducers.ts b/src/app/app.metareducers.ts index 131d240b79..7675840969 100644 --- a/src/app/app.metareducers.ts +++ b/src/app/app.metareducers.ts @@ -1,4 +1,5 @@ import { StoreActionTypes } from './store.actions'; +import { AuthActionTypes } from './core/auth/auth.actions'; // fallback ngrx debugger let actionCounter = 0; @@ -28,10 +29,26 @@ export function universalMetaReducer(reducer) { } } +// const clearStateActions = [ +// AuthActionTypes.LOG_OUT_SUCCESS, +// ]; +// +// export function clearStateMetaReducer(reducer) { +// return function (state, action) { +// +// if (clearStateActions.includes(action.type)) { +// state = {}; +// } +// +// return reducer(state, action); +// }; +// } + export const debugMetaReducers = [ debugMetaReducer ]; export const appMetaReducers = [ - universalMetaReducer + universalMetaReducer, + // clearStateMetaReducer ]; diff --git a/src/app/core/auth/auth.service.ts b/src/app/core/auth/auth.service.ts index d5f76c106b..588d9e2675 100644 --- a/src/app/core/auth/auth.service.ts +++ b/src/app/core/auth/auth.service.ts @@ -460,8 +460,9 @@ export class AuthService { * Refresh route navigated */ public refreshAfterLogout() { - // Hard redirect to home page, so that all state is definitely lost - this._window.nativeWindow.location.href = '/home'; + // Hard redirect to the reload page with a unique number behind it + // so that all state is definitely lost + this._window.nativeWindow.location.href = `/reload/${new Date().getTime()}`; } /** From df2cc2217250e29468b8b85c0178352ae1235e1b Mon Sep 17 00:00:00 2001 From: Art Lowel Date: Fri, 24 Apr 2020 14:06:37 +0200 Subject: [PATCH 09/10] remove clearStateMetaReducer --- src/app/app.metareducers.ts | 19 +------------------ 1 file changed, 1 insertion(+), 18 deletions(-) diff --git a/src/app/app.metareducers.ts b/src/app/app.metareducers.ts index 7675840969..131d240b79 100644 --- a/src/app/app.metareducers.ts +++ b/src/app/app.metareducers.ts @@ -1,5 +1,4 @@ import { StoreActionTypes } from './store.actions'; -import { AuthActionTypes } from './core/auth/auth.actions'; // fallback ngrx debugger let actionCounter = 0; @@ -29,26 +28,10 @@ export function universalMetaReducer(reducer) { } } -// const clearStateActions = [ -// AuthActionTypes.LOG_OUT_SUCCESS, -// ]; -// -// export function clearStateMetaReducer(reducer) { -// return function (state, action) { -// -// if (clearStateActions.includes(action.type)) { -// state = {}; -// } -// -// return reducer(state, action); -// }; -// } - export const debugMetaReducers = [ debugMetaReducer ]; export const appMetaReducers = [ - universalMetaReducer, - // clearStateMetaReducer + universalMetaReducer ]; From 3894b3615d4d788c9d760c965ca87c367655d0d8 Mon Sep 17 00:00:00 2001 From: Kristof De Langhe Date: Tue, 12 May 2020 17:20:02 +0200 Subject: [PATCH 10/10] 70373: Test import fix --- .../impersonate-navbar/impersonate-navbar.component.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/shared/impersonate-navbar/impersonate-navbar.component.spec.ts b/src/app/shared/impersonate-navbar/impersonate-navbar.component.spec.ts index 16e445f183..e6ddc3075d 100644 --- a/src/app/shared/impersonate-navbar/impersonate-navbar.component.spec.ts +++ b/src/app/shared/impersonate-navbar/impersonate-navbar.component.spec.ts @@ -8,7 +8,7 @@ import { AuthService } from '../../core/auth/auth.service'; import { Store, StoreModule } from '@ngrx/store'; import { authReducer, AuthState } from '../../core/auth/auth.reducer'; import { AuthTokenInfo } from '../../core/auth/models/auth-token-info.model'; -import { EPersonMock } from '../testing/eperson-mock'; +import { EPersonMock } from '../testing/eperson.mock'; import { AppState } from '../../app.reducer'; import { By } from '@angular/platform-browser';