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.spec.ts b/src/app/+admin/admin-access-control/epeople-registry/eperson-form/eperson-form.component.spec.ts index 292b49ac6b..693f3cf916 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 { getMockFormBuilderService } from '../../../../shared/mocks/form-builder import { getMockTranslateService } from '../../../../shared/mocks/translate.service.mock'; import { NotificationsServiceStub } from '../../../../shared/testing/notifications-service.stub'; import { TranslateLoaderMock } from '../../../../shared/mocks/translate-loader.mock'; +import { AuthService } from '../../../../core/auth/auth.service'; +import { AuthServiceStub } from '../../../../shared/testing/auth-service.stub'; describe('EPersonFormComponent', () => { let component: EPersonFormComponent; @@ -40,6 +42,7 @@ describe('EPersonFormComponent', () => { let mockEPeople; let ePersonDataServiceStub: any; + let authService: AuthServiceStub; beforeEach(async(() => { mockEPeople = [EPersonMock, EPersonMock2]; @@ -104,6 +107,7 @@ describe('EPersonFormComponent', () => { }; builderService = getMockFormBuilderService(); translateService = getMockTranslateService(); + authService = new AuthServiceStub(); TestBed.configureTestingModule({ imports: [CommonModule, NgbModule, FormsModule, ReactiveFormsModule, BrowserModule, TranslateModule.forRoot({ @@ -125,6 +129,7 @@ describe('EPersonFormComponent', () => { { provide: Store, useValue: {} }, { provide: RemoteDataBuildService, useValue: {} }, { provide: HALEndpointService, useValue: {} }, + { provide: AuthService, useValue: authService }, EPeopleRegistryComponent ], schemas: [NO_ERRORS_SCHEMA] @@ -228,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/+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..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 @@ -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,22 @@ 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; + if (hasValue(eperson)) { + this.isImpersonated = this.authService.isImpersonatingUser(eperson.id); + } })); } @@ -364,6 +392,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.stopImpersonatingAndRefresh(); + this.isImpersonated = false; + } + /** * Cancel the current edit when component is destroyed & unsub all subscriptions */ diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts index ead1157df1..0ba0851e4e 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -66,6 +66,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/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.spec.ts b/src/app/core/auth/auth.effects.spec.ts index e231857159..5aaced609e 100644 --- a/src/app/core/auth/auth.effects.spec.ts +++ b/src/app/core/auth/auth.effects.spec.ts @@ -235,7 +235,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.effects.ts b/src/app/core/auth/auth.effects.ts index d153748fb9..717aaff01e 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)))); }) ); @@ -193,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.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.reducer.spec.ts b/src/app/core/auth/auth.reducer.spec.ts index 1606ed9185..cf934a7f47 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/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.spec.ts b/src/app/core/auth/auth.service.spec.ts index 89d8cdce4e..3b6fae4dd1 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'; @@ -332,5 +332,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); + }); + }); + }); }); }); diff --git a/src/app/core/auth/auth.service.ts b/src/app/core/auth/auth.service.ts index 0f5c06bbc9..588d9e2675 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 { 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, @@ -33,6 +39,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. @@ -163,7 +170,7 @@ export class AuthService { } /** - * Returns the authenticated user + * Returns the authenticated user by href * @returns {User} */ public retrieveAuthenticatedUserByHref(userHref: string): Observable { @@ -172,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. */ @@ -430,9 +460,9 @@ 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'; + // 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()}`; } /** @@ -469,4 +499,51 @@ 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); + } + + /** + * Stop impersonating EPerson and refresh the store/ui + */ + stopImpersonatingAndRefresh() { + this.stopImpersonating(); + 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/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/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..e6ddc3075d --- /dev/null +++ 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(); + }); + }); +}); 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 2b4633fb04..67d7db5c5d 100644 --- a/src/app/shared/shared.module.ts +++ b/src/app/shared/shared.module.ts @@ -193,6 +193,7 @@ import { ModifyItemOverviewComponent } from '../+item-page/edit-item-page/modify 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'; import { ResourcePoliciesComponent } from './resource-policies/resource-policies.component'; import { NgForTrackByIdDirective } from './ng-for-track-by-id.directive'; import { ResourcePolicyFormComponent } from './resource-policies/form/resource-policy-form.component'; @@ -380,6 +381,7 @@ const COMPONENTS = [ PublicationSearchResultListElementComponent, ItemVersionsNoticeComponent, ModifyItemOverviewComponent, + ImpersonateNavbarComponent, ResourcePoliciesComponent, ResourcePolicyFormComponent, EpersonGroupListComponent, diff --git a/src/app/shared/testing/auth-service.stub.ts b/src/app/shared/testing/auth-service.stub.ts index 5c2d048740..31143bc856 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 '../remote-data.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; + } } diff --git a/src/assets/i18n/en.json5 b/src/assets/i18n/en.json5 index 0e3810bdf9..4173fa1cf2 100644 --- a/src/assets/i18n/en.json5 +++ b/src/assets/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", @@ -1821,6 +1829,8 @@ "nav.statistics.header": "Statistics", + "nav.stop-impersonating": "Stop impersonating EPerson", + "orgunit.listelement.badge": "Organizational Unit",