Merge remote-tracking branch 'remotes/origin/master' into #601-resource-policies

# Conflicts:
#	src/app/shared/shared.module.ts
This commit is contained in:
Giuseppe Digilio
2020-05-21 19:35:35 +02:00
27 changed files with 583 additions and 81 deletions

View File

@@ -14,6 +14,18 @@
[formLayout]="formLayout"
(cancel)="onCancel()"
(submitForm)="onSubmit()">
<button class="btn btn-light" [disabled]="!(canReset$ | async)">
<i class="fa fa-key"></i> {{'admin.access-control.epeople.actions.reset' | translate}}
</button>
<button class="btn btn-light" [disabled]="!(canDelete$ | async)">
<i class="fa fa-trash"></i> {{'admin.access-control.epeople.actions.delete' | translate}}
</button>
<button *ngIf="!isImpersonated" class="btn btn-light" [disabled]="!(canImpersonate$ | async)" (click)="impersonate()">
<i class="fa fa-user-secret"></i> {{'admin.access-control.epeople.actions.impersonate' | translate}}
</button>
<button *ngIf="isImpersonated" class="btn btn-light" (click)="stopImpersonating()">
<i class="fa fa-user-secret"></i> {{'admin.access-control.epeople.actions.stop-impersonating' | translate}}
</button>
</ds-form>
<div *ngIf="epersonService.getActiveEPerson() | async">

View File

@@ -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);
});
});
});

View File

@@ -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<any> = 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<boolean> = 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<boolean> = 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<boolean> = 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
*/

View File

@@ -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' },

View File

@@ -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 ;
}
}

View File

@@ -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);
});

View File

@@ -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<Action> = 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<EPerson>;
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)))

View File

@@ -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();
}

View File

@@ -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
};
});

View File

@@ -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:

View File

@@ -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);
});
});
});
});
});

View File

@@ -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<EPerson> {
@@ -172,6 +179,29 @@ export class AuthService {
)
}
/**
* Returns the authenticated user by id
* @returns {User}
*/
public retrieveAuthenticatedUserById(userId: string): Observable<EPerson> {
return this.epersonService.findById(userId).pipe(
getAllSucceededRemoteDataPayload()
)
}
/**
* Returns the authenticated user from the store
* @returns {User}
*/
public getAuthenticatedUserFromStore(): Observable<EPerson> {
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;
}
}

View File

@@ -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.

View File

@@ -8,6 +8,7 @@
<ds-search-navbar></ds-search-navbar>
<ds-lang-switch></ds-lang-switch>
<ds-auth-nav-menu></ds-auth-nav-menu>
<ds-impersonate-navbar></ds-impersonate-navbar>
<div class="pl-2">
<button class="navbar-toggler" type="button" (click)="toggleNavbar()"
aria-controls="collapsingNav"

View File

@@ -13,6 +13,8 @@ import { NotificationsService } from '../shared/notifications/notifications.serv
import { authReducer } from '../core/auth/auth.reducer';
import { createSuccessfulRemoteDataObject$ } from '../shared/remote-data.utils';
import { createPaginatedList } from '../shared/testing/utils.test';
import { of } from 'rxjs/internal/observable/of';
import { AuthService } from '../core/auth/auth.service';
describe('ProfilePageComponent', () => {
let component: ProfilePageComponent;
@@ -20,11 +22,13 @@ describe('ProfilePageComponent', () => {
let user;
let authState;
let authService;
let epersonService;
let notificationsService;
function init() {
user = Object.assign(new EPerson(), {
id: 'userId',
groups: createSuccessfulRemoteDataObject$(createPaginatedList([]))
});
authState = {
@@ -32,9 +36,12 @@ describe('ProfilePageComponent', () => {
loaded: true,
loading: false,
authToken: new AuthTokenInfo('test_token'),
user: user
userId: user.id
};
authService = jasmine.createSpyObj('authService', {
getAuthenticatedUserFromStore: of(user)
});
epersonService = jasmine.createSpyObj('epersonService', {
findById: createSuccessfulRemoteDataObject$(user)
});
@@ -52,7 +59,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();

View File

@@ -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<AppState>,
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(),

View File

@@ -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({
@@ -64,12 +76,7 @@ describe('AuthNavMenuComponent', () => {
],
providers: [
{ provide: HostWindowService, useValue: window },
{
provide: AuthService, useValue: {
setRedirectUrl: () => { /*empty*/
}
}
}
{ provide: AuthService, useValue: authService }
],
schemas: [
CUSTOM_ELEMENTS_SCHEMA
@@ -244,6 +251,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({
@@ -262,12 +270,7 @@ describe('AuthNavMenuComponent', () => {
],
providers: [
{ provide: HostWindowService, useValue: window },
{
provide: AuthService, useValue: {
setRedirectUrl: () => { /*empty*/
}
}
}
{ provide: AuthService, useValue: authService }
],
schemas: [
CUSTOM_ELEMENTS_SCHEMA

View File

@@ -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<AppState>,
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),

View File

@@ -12,6 +12,8 @@ import { AppState, storeModuleConfig } from '../../../app.reducer';
import { TranslateLoaderMock } from '../../mocks/translate-loader.mock';
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, {
@@ -54,6 +64,9 @@ describe('UserMenuComponent', () => {
}
})
],
providers: [
{ provide: AuthService, useValue: authService }
],
declarations: [
UserMenuComponent
],
@@ -98,7 +111,7 @@ describe('UserMenuComponent', () => {
b: true
}));
expect(component.user$).toBeObservable(cold('c', {
expect(component.user$).toBeObservable(cold('(c|)', {
c: EPersonMock
}));
@@ -137,7 +150,7 @@ describe('UserMenuComponent', () => {
b: false
}));
expect(component.user$).toBeObservable(cold('c', {
expect(component.user$).toBeObservable(cold('(c|)', {
c: EPersonMock
}));

View File

@@ -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<AppState>) {
constructor(private store: Store<AppState>,
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();
}
}

View File

@@ -45,6 +45,8 @@
</ds-dynamic-form>
<ng-content></ng-content>
<div *ngIf="displaySubmit">
<hr>
<div class="form-group row">

View File

@@ -0,0 +1,7 @@
<ul class="navbar-nav" *ngIf="(isAuthenticated$ | async) && isImpersonating">
<li class="nav-item">
<button class="btn btn-sm btn-dark" ngbTooltip="{{'nav.stop-impersonating' | translate}}" (click)="stopImpersonating()">
<i class="fa fa-user-secret"></i>
</button>
</li>
</ul>

View File

@@ -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<ImpersonateNavbarComponent>;
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<AppState>) => {
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();
});
});
});

View File

@@ -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<string>}
*/
isAuthenticated$: Observable<boolean>;
/**
* Is the user currently impersonating another user?
*/
isImpersonating: boolean;
constructor(private store: Store<AppState>,
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();
}
}

View File

@@ -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,

View File

@@ -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<EPerson> {
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;
}
}

View File

@@ -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",