diff --git a/src/app/+item-page/edit-item-page/edit-item-page.module.ts b/src/app/+item-page/edit-item-page/edit-item-page.module.ts index 71924cf6c8..2cbd0c57d1 100644 --- a/src/app/+item-page/edit-item-page/edit-item-page.module.ts +++ b/src/app/+item-page/edit-item-page/edit-item-page.module.ts @@ -22,6 +22,7 @@ import { EditRelationshipComponent } from './item-relationships/edit-relationshi import { EditRelationshipListComponent } from './item-relationships/edit-relationship-list/edit-relationship-list.component'; import { ItemMoveComponent } from './item-move/item-move.component'; import { VirtualMetadataComponent } from './virtual-metadata/virtual-metadata.component'; +import { ItemVersionHistoryComponent } from './item-version-history/item-version-history.component'; /** * Module that contains all components related to the Edit Item page administrator functionality @@ -47,6 +48,7 @@ import { VirtualMetadataComponent } from './virtual-metadata/virtual-metadata.co ItemMetadataComponent, ItemRelationshipsComponent, ItemBitstreamsComponent, + ItemVersionHistoryComponent, EditInPlaceFieldComponent, EditRelationshipComponent, EditRelationshipListComponent, diff --git a/src/app/+item-page/edit-item-page/edit-item-page.routing.module.ts b/src/app/+item-page/edit-item-page/edit-item-page.routing.module.ts index 84cdeec3d0..e4b1b06730 100644 --- a/src/app/+item-page/edit-item-page/edit-item-page.routing.module.ts +++ b/src/app/+item-page/edit-item-page/edit-item-page.routing.module.ts @@ -1,4 +1,3 @@ -import { ItemPageResolver } from '../item-page.resolver'; import { NgModule } from '@angular/core'; import { RouterModule } from '@angular/router'; import { EditItemPageComponent } from './edit-item-page.component'; @@ -14,6 +13,7 @@ import { ItemCollectionMapperComponent } from './item-collection-mapper/item-col import { ItemMoveComponent } from './item-move/item-move.component'; import { ItemRelationshipsComponent } from './item-relationships/item-relationships.component'; import { I18nBreadcrumbResolver } from '../../core/breadcrumbs/i18n-breadcrumb.resolver'; +import { ItemVersionHistoryComponent } from './item-version-history/item-version-history.component'; export const ITEM_EDIT_WITHDRAW_PATH = 'withdraw'; export const ITEM_EDIT_REINSTATE_PATH = 'reinstate'; @@ -75,6 +75,11 @@ export const ITEM_EDIT_MOVE_PATH = 'move'; /* TODO - change when curate page exists */ component: ItemBitstreamsComponent, data: { title: 'item.edit.tabs.curate.title', showBreadcrumbs: true } + }, + { + path: 'versionhistory', + component: ItemVersionHistoryComponent, + data: { title: 'item.edit.tabs.versionhistory.title', showBreadcrumbs: true } } ] }, diff --git a/src/app/+item-page/edit-item-page/item-version-history/item-version-history.component.html b/src/app/+item-page/edit-item-page/item-version-history/item-version-history.component.html new file mode 100644 index 0000000000..acabbd1010 --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-version-history/item-version-history.component.html @@ -0,0 +1,6 @@ +
+ +
+
+ +
diff --git a/src/app/+item-page/edit-item-page/item-version-history/item-version-history.component.spec.ts b/src/app/+item-page/edit-item-page/item-version-history/item-version-history.component.spec.ts new file mode 100644 index 0000000000..9bc39649f4 --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-version-history/item-version-history.component.spec.ts @@ -0,0 +1,44 @@ +import { ItemVersionHistoryComponent } from './item-version-history.component'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { VarDirective } from '../../../shared/utils/var.directive'; +import { RouterTestingModule } from '@angular/router/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { Item } from '../../../core/shared/item.model'; +import { ActivatedRoute } from '@angular/router'; +import { of as observableOf } from 'rxjs'; +import { createSuccessfulRemoteDataObject } from '../../../shared/testing/utils'; + +describe('ItemVersionHistoryComponent', () => { + let component: ItemVersionHistoryComponent; + let fixture: ComponentFixture; + + const item = Object.assign(new Item(), { + uuid: 'item-identifier-1', + handle: '123456789/1', + }); + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ItemVersionHistoryComponent, VarDirective], + imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([])], + providers: [ + { provide: ActivatedRoute, useValue: { parent: { data: observableOf({ item: createSuccessfulRemoteDataObject(item) }) } } } + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(ItemVersionHistoryComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should initialize the itemRD$ from the route\'s data', (done) => { + component.itemRD$.subscribe((itemRD) => { + expect(itemRD.payload).toBe(item); + done(); + }); + }); +}); diff --git a/src/app/+item-page/edit-item-page/item-version-history/item-version-history.component.ts b/src/app/+item-page/edit-item-page/item-version-history/item-version-history.component.ts new file mode 100644 index 0000000000..ce662c5753 --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-version-history/item-version-history.component.ts @@ -0,0 +1,35 @@ +import { Component } from '@angular/core'; +import { Observable } from 'rxjs/internal/Observable'; +import { RemoteData } from '../../../core/data/remote-data'; +import { Item } from '../../../core/shared/item.model'; +import { map } from 'rxjs/operators'; +import { getSucceededRemoteData } from '../../../core/shared/operators'; +import { ActivatedRoute } from '@angular/router'; +import { AlertType } from '../../../shared/alert/aletr-type'; + +@Component({ + selector: 'ds-item-version-history', + templateUrl: './item-version-history.component.html' +}) +/** + * Component for listing and managing an item's version history + */ +export class ItemVersionHistoryComponent { + /** + * The item to display the version history for + */ + itemRD$: Observable>; + + /** + * The AlertType enumeration + * @type {AlertType} + */ + AlertTypeEnum = AlertType; + + constructor(private route: ActivatedRoute) { + } + + ngOnInit(): void { + this.itemRD$ = this.route.parent.data.pipe(map((data) => data.item)).pipe(getSucceededRemoteData()) as Observable>; + } +} diff --git a/src/app/+item-page/full/full-item-page.component.html b/src/app/+item-page/full/full-item-page.component.html index c453df6bff..29d3582492 100644 --- a/src/app/+item-page/full/full-item-page.component.html +++ b/src/app/+item-page/full/full-item-page.component.html @@ -1,6 +1,7 @@
+
diff --git a/src/app/+item-page/item-page.module.ts b/src/app/+item-page/item-page.module.ts index 5c54becdde..8d5d78ddd1 100644 --- a/src/app/+item-page/item-page.module.ts +++ b/src/app/+item-page/item-page.module.ts @@ -59,7 +59,7 @@ import { AbstractIncrementalListComponent } from './simple/abstract-incremental- MetadataRepresentationListComponent, RelatedEntitiesSearchComponent, TabbedRelatedEntitiesSearchComponent, - AbstractIncrementalListComponent + AbstractIncrementalListComponent, ], exports: [ ItemComponent, diff --git a/src/app/+item-page/item-page.resolver.ts b/src/app/+item-page/item-page.resolver.ts index 0f73dc6170..501bb34d2c 100644 --- a/src/app/+item-page/item-page.resolver.ts +++ b/src/app/+item-page/item-page.resolver.ts @@ -28,6 +28,7 @@ export class ItemPageResolver implements Resolve> { followLink('owningCollection'), followLink('bundles'), followLink('relationships'), + followLink('version', undefined, true, followLink('versionhistory')), ).pipe( find((RD) => hasValue(RD.error) || RD.hasSucceeded), ); diff --git a/src/app/+item-page/simple/item-page.component.html b/src/app/+item-page/simple/item-page.component.html index b4b32fb05c..0e8abf0a02 100644 --- a/src/app/+item-page/simple/item-page.component.html +++ b/src/app/+item-page/simple/item-page.component.html @@ -1,8 +1,10 @@
+ +
diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts index fda558a5dd..2927cd4e65 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -63,16 +63,29 @@ export function getDSOPath(dso: DSpaceObject): string { { path: COMMUNITY_MODULE_PATH, loadChildren: './+community-page/community-page.module#CommunityPageModule' }, { path: COLLECTION_MODULE_PATH, loadChildren: './+collection-page/collection-page.module#CollectionPageModule' }, { path: ITEM_MODULE_PATH, loadChildren: './+item-page/item-page.module#ItemPageModule' }, - { path: 'mydspace', loadChildren: './+my-dspace-page/my-dspace-page.module#MyDSpacePageModule', canActivate: [AuthenticatedGuard] }, + { + path: 'mydspace', + loadChildren: './+my-dspace-page/my-dspace-page.module#MyDSpacePageModule', + canActivate: [AuthenticatedGuard] + }, { path: 'search', loadChildren: './+search-page/search-page.module#SearchPageModule' }, { path: 'browse', loadChildren: './+browse-by/browse-by.module#BrowseByModule'}, { path: ADMIN_MODULE_PATH, loadChildren: './+admin/admin.module#AdminModule', canActivate: [AuthenticatedGuard] }, { path: 'login', loadChildren: './+login-page/login-page.module#LoginPageModule' }, { path: 'logout', loadChildren: './+logout-page/logout-page.module#LogoutPageModule' }, { path: 'submit', loadChildren: './+submit-page/submit-page.module#SubmitPageModule' }, - { path: 'workspaceitems', loadChildren: './+workspaceitems-edit-page/workspaceitems-edit-page.module#WorkspaceitemsEditPageModule' }, - { path: 'workflowitems', loadChildren: './+workflowitems-edit-page/workflowitems-edit-page.module#WorkflowItemsEditPageModule' }, - { path: PROFILE_MODULE_PATH, loadChildren: './profile-page/profile-page.module#ProfilePageModule', canActivate: [AuthenticatedGuard] }, + { + path: 'workspaceitems', + loadChildren: './+workspaceitems-edit-page/workspaceitems-edit-page.module#WorkspaceitemsEditPageModule' + }, + { + path: 'workflowitems', + loadChildren: './+workflowitems-edit-page/workflowitems-edit-page.module#WorkflowItemsEditPageModule' + }, + { + path: PROFILE_MODULE_PATH, + loadChildren: './profile-page/profile-page.module#ProfilePageModule', canActivate: [AuthenticatedGuard] + }, { path: '**', pathMatch: 'full', component: PageNotFoundComponent }, ], { diff --git a/src/app/core/auth/auth-request.service.ts b/src/app/core/auth/auth-request.service.ts index 64cc5a9606..465fb69dd2 100644 --- a/src/app/core/auth/auth-request.service.ts +++ b/src/app/core/auth/auth-request.service.ts @@ -9,6 +9,7 @@ import { AuthGetRequest, AuthPostRequest, GetRequest, PostRequest, RestRequest } import { AuthStatusResponse, ErrorResponse } from '../cache/response.models'; import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service'; import { getResponseFromEntry } from '../shared/operators'; +import { HttpClient } from '@angular/common/http'; @Injectable() export class AuthRequestService { @@ -16,7 +17,8 @@ export class AuthRequestService { protected browseEndpoint = ''; constructor(protected halService: HALEndpointService, - protected requestService: RequestService) { + protected requestService: RequestService, + private http: HttpClient) { } protected fetchRequest(request: RestRequest): Observable { @@ -36,7 +38,7 @@ export class AuthRequestService { return isNotEmpty(method) ? `${endpoint}/${method}` : `${endpoint}`; } - public postToEndpoint(method: string, body: any, options?: HttpOptions): Observable { + public postToEndpoint(method: string, body?: any, options?: HttpOptions): Observable { return this.halService.getEndpoint(this.linkName).pipe( filter((href: string) => isNotEmpty(href)), map((endpointURL) => this.getEndpointByMethod(endpointURL, method)), diff --git a/src/app/core/auth/auth.actions.ts b/src/app/core/auth/auth.actions.ts index 2681ed39a2..2c2224e878 100644 --- a/src/app/core/auth/auth.actions.ts +++ b/src/app/core/auth/auth.actions.ts @@ -5,6 +5,8 @@ import { type } from '../../shared/ngrx/type'; // import models import { EPerson } from '../eperson/models/eperson.model'; import { AuthTokenInfo } from './models/auth-token-info.model'; +import { AuthMethod } from './models/auth.method'; +import { AuthStatus } from './models/auth-status.model'; export const AuthActionTypes = { AUTHENTICATE: type('dspace/auth/AUTHENTICATE'), @@ -14,12 +16,16 @@ export const AuthActionTypes = { AUTHENTICATED_ERROR: type('dspace/auth/AUTHENTICATED_ERROR'), AUTHENTICATED_SUCCESS: type('dspace/auth/AUTHENTICATED_SUCCESS'), CHECK_AUTHENTICATION_TOKEN: type('dspace/auth/CHECK_AUTHENTICATION_TOKEN'), - CHECK_AUTHENTICATION_TOKEN_ERROR: type('dspace/auth/CHECK_AUTHENTICATION_TOKEN_ERROR'), + CHECK_AUTHENTICATION_TOKEN_COOKIE: type('dspace/auth/CHECK_AUTHENTICATION_TOKEN_COOKIE'), + RETRIEVE_AUTH_METHODS: type('dspace/auth/RETRIEVE_AUTH_METHODS'), + RETRIEVE_AUTH_METHODS_SUCCESS: type('dspace/auth/RETRIEVE_AUTH_METHODS_SUCCESS'), + RETRIEVE_AUTH_METHODS_ERROR: type('dspace/auth/RETRIEVE_AUTH_METHODS_ERROR'), REDIRECT_TOKEN_EXPIRED: type('dspace/auth/REDIRECT_TOKEN_EXPIRED'), REDIRECT_AUTHENTICATION_REQUIRED: type('dspace/auth/REDIRECT_AUTHENTICATION_REQUIRED'), REFRESH_TOKEN: type('dspace/auth/REFRESH_TOKEN'), REFRESH_TOKEN_SUCCESS: type('dspace/auth/REFRESH_TOKEN_SUCCESS'), REFRESH_TOKEN_ERROR: type('dspace/auth/REFRESH_TOKEN_ERROR'), + RETRIEVE_TOKEN: type('dspace/auth/RETRIEVE_TOKEN'), ADD_MESSAGE: type('dspace/auth/ADD_MESSAGE'), RESET_MESSAGES: type('dspace/auth/RESET_MESSAGES'), LOG_OUT: type('dspace/auth/LOG_OUT'), @@ -95,7 +101,7 @@ export class AuthenticatedErrorAction implements Action { payload: Error; constructor(payload: Error) { - this.payload = payload ; + this.payload = payload; } } @@ -109,7 +115,7 @@ export class AuthenticationErrorAction implements Action { payload: Error; constructor(payload: Error) { - this.payload = payload ; + this.payload = payload; } } @@ -138,11 +144,11 @@ export class CheckAuthenticationTokenAction implements Action { /** * Check Authentication Token Error. - * @class CheckAuthenticationTokenErrorAction + * @class CheckAuthenticationTokenCookieAction * @implements {Action} */ -export class CheckAuthenticationTokenErrorAction implements Action { - public type: string = AuthActionTypes.CHECK_AUTHENTICATION_TOKEN_ERROR; +export class CheckAuthenticationTokenCookieAction implements Action { + public type: string = AuthActionTypes.CHECK_AUTHENTICATION_TOKEN_COOKIE; } /** @@ -152,7 +158,9 @@ export class CheckAuthenticationTokenErrorAction implements Action { */ export class LogOutAction implements Action { public type: string = AuthActionTypes.LOG_OUT; - constructor(public payload?: any) {} + + constructor(public payload?: any) { + } } /** @@ -165,7 +173,7 @@ export class LogOutErrorAction implements Action { payload: Error; constructor(payload: Error) { - this.payload = payload ; + this.payload = payload; } } @@ -176,7 +184,9 @@ export class LogOutErrorAction implements Action { */ export class LogOutSuccessAction implements Action { public type: string = AuthActionTypes.LOG_OUT_SUCCESS; - constructor(public payload?: any) {} + + constructor(public payload?: any) { + } } /** @@ -189,7 +199,7 @@ export class RedirectWhenAuthenticationIsRequiredAction implements Action { payload: string; constructor(message: string) { - this.payload = message ; + this.payload = message; } } @@ -203,7 +213,7 @@ export class RedirectWhenTokenExpiredAction implements Action { payload: string; constructor(message: string) { - this.payload = message ; + this.payload = message; } } @@ -244,6 +254,15 @@ export class RefreshTokenErrorAction implements Action { public type: string = AuthActionTypes.REFRESH_TOKEN_ERROR; } +/** + * Retrieve authentication token. + * @class RetrieveTokenAction + * @implements {Action} + */ +export class RetrieveTokenAction implements Action { + public type: string = AuthActionTypes.RETRIEVE_TOKEN; +} + /** * Sign up. * @class RegistrationAction @@ -268,7 +287,7 @@ export class RegistrationErrorAction implements Action { payload: Error; constructor(payload: Error) { - this.payload = payload ; + this.payload = payload; } } @@ -309,6 +328,45 @@ export class ResetAuthenticationMessagesAction implements Action { public type: string = AuthActionTypes.RESET_MESSAGES; } +// // Next three Actions are used by dynamic login methods +/** + * Action that triggers an effect fetching the authentication methods enabled ant the backend + * @class RetrieveAuthMethodsAction + * @implements {Action} + */ +export class RetrieveAuthMethodsAction implements Action { + public type: string = AuthActionTypes.RETRIEVE_AUTH_METHODS; + + payload: AuthStatus; + + constructor(authStatus: AuthStatus) { + this.payload = authStatus; + } +} + +/** + * Get Authentication methods enabled at the backend + * @class RetrieveAuthMethodsSuccessAction + * @implements {Action} + */ +export class RetrieveAuthMethodsSuccessAction implements Action { + public type: string = AuthActionTypes.RETRIEVE_AUTH_METHODS_SUCCESS; + payload: AuthMethod[]; + + constructor(authMethods: AuthMethod[] ) { + this.payload = authMethods; + } +} + +/** + * Set password as default authentication method on error + * @class RetrieveAuthMethodsErrorAction + * @implements {Action} + */ +export class RetrieveAuthMethodsErrorAction implements Action { + public type: string = AuthActionTypes.RETRIEVE_AUTH_METHODS_ERROR; +} + /** * Change the redirect url. * @class SetRedirectUrlAction @@ -319,7 +377,7 @@ export class SetRedirectUrlAction implements Action { payload: string; constructor(url: string) { - this.payload = url ; + this.payload = url; } } @@ -378,13 +436,21 @@ export type AuthActions | AuthenticationErrorAction | AuthenticationSuccessAction | CheckAuthenticationTokenAction - | CheckAuthenticationTokenErrorAction + | CheckAuthenticationTokenCookieAction | RedirectWhenAuthenticationIsRequiredAction | RedirectWhenTokenExpiredAction | RegistrationAction | RegistrationErrorAction | RegistrationSuccessAction | AddAuthenticationMessageAction + | RefreshTokenAction + | RefreshTokenErrorAction + | RefreshTokenSuccessAction + | ResetAuthenticationMessagesAction + | RetrieveAuthMethodsAction + | RetrieveAuthMethodsSuccessAction + | RetrieveAuthMethodsErrorAction + | RetrieveTokenAction | ResetAuthenticationMessagesAction | RetrieveAuthenticatedEpersonAction | RetrieveAuthenticatedEpersonErrorAction diff --git a/src/app/core/auth/auth.effects.spec.ts b/src/app/core/auth/auth.effects.spec.ts index ca2fa2d08a..194a51e31e 100644 --- a/src/app/core/auth/auth.effects.spec.ts +++ b/src/app/core/auth/auth.effects.spec.ts @@ -14,20 +14,26 @@ import { AuthenticatedSuccessAction, AuthenticationErrorAction, AuthenticationSuccessAction, - CheckAuthenticationTokenErrorAction, + CheckAuthenticationTokenCookieAction, LogOutErrorAction, LogOutSuccessAction, RefreshTokenErrorAction, RefreshTokenSuccessAction, RetrieveAuthenticatedEpersonAction, RetrieveAuthenticatedEpersonErrorAction, - RetrieveAuthenticatedEpersonSuccessAction + RetrieveAuthenticatedEpersonSuccessAction, + RetrieveAuthMethodsAction, + RetrieveAuthMethodsErrorAction, + RetrieveAuthMethodsSuccessAction, + RetrieveTokenAction } from './auth.actions'; -import { AuthServiceStub } from '../../shared/testing/auth-service.stub'; +import { authMethodsMock, AuthServiceStub } from '../../shared/testing/auth-service.stub'; import { AuthService } from './auth.service'; import { AuthState } from './auth.reducer'; import { EPersonMock } from '../../shared/testing/eperson.mock'; +import { EPersonMock } from '../../shared/testing/eperson-mock'; +import { AuthStatus } from './models/auth-status.model'; describe('AuthEffects', () => { let authEffects: AuthEffects; @@ -169,13 +175,56 @@ describe('AuthEffects', () => { actions = hot('--a-', { a: { type: AuthActionTypes.CHECK_AUTHENTICATION_TOKEN, payload: token } }); - const expected = cold('--b-', { b: new CheckAuthenticationTokenErrorAction() }); + const expected = cold('--b-', { b: new CheckAuthenticationTokenCookieAction() }); expect(authEffects.checkToken$).toBeObservable(expected); }); }) }); + describe('checkTokenCookie$', () => { + + describe('when check token succeeded', () => { + it('should return a RETRIEVE_TOKEN action in response to a CHECK_AUTHENTICATION_TOKEN_COOKIE action when authenticated is true', () => { + spyOn((authEffects as any).authService, 'checkAuthenticationCookie').and.returnValue( + observableOf( + { + authenticated: true + }) + ); + actions = hot('--a-', { a: { type: AuthActionTypes.CHECK_AUTHENTICATION_TOKEN_COOKIE } }); + + const expected = cold('--b-', { b: new RetrieveTokenAction() }); + + expect(authEffects.checkTokenCookie$).toBeObservable(expected); + }); + + it('should return a RETRIEVE_AUTH_METHODS action in response to a CHECK_AUTHENTICATION_TOKEN_COOKIE action when authenticated is false', () => { + spyOn((authEffects as any).authService, 'checkAuthenticationCookie').and.returnValue( + observableOf( + { authenticated: false }) + ); + actions = hot('--a-', { a: { type: AuthActionTypes.CHECK_AUTHENTICATION_TOKEN_COOKIE } }); + + const expected = cold('--b-', { b: new RetrieveAuthMethodsAction({ authenticated: false } as AuthStatus) }); + + expect(authEffects.checkTokenCookie$).toBeObservable(expected); + }); + }); + + describe('when check token failed', () => { + it('should return a AUTHENTICATED_ERROR action in response to a CHECK_AUTHENTICATION_TOKEN_COOKIE action', () => { + spyOn((authEffects as any).authService, 'checkAuthenticationCookie').and.returnValue(observableThrow(new Error('Message Error test'))); + + actions = hot('--a-', { a: { type: AuthActionTypes.CHECK_AUTHENTICATION_TOKEN_COOKIE, payload: token } }); + + const expected = cold('--b-', { b: new AuthenticatedErrorAction(new Error('Message Error test')) }); + + expect(authEffects.checkTokenCookie$).toBeObservable(expected); + }); + }) + }); + describe('retrieveAuthenticatedEperson$', () => { describe('when request is successful', () => { @@ -232,6 +281,38 @@ describe('AuthEffects', () => { }) }); + describe('retrieveToken$', () => { + describe('when user is authenticated', () => { + it('should return a AUTHENTICATE_SUCCESS action in response to a RETRIEVE_TOKEN action', () => { + actions = hot('--a-', { + a: { + type: AuthActionTypes.RETRIEVE_TOKEN + } + }); + + const expected = cold('--b-', { b: new AuthenticationSuccessAction(token) }); + + expect(authEffects.retrieveToken$).toBeObservable(expected); + }); + }); + + describe('when user is not authenticated', () => { + it('should return a AUTHENTICATE_ERROR action in response to a RETRIEVE_TOKEN action', () => { + spyOn((authEffects as any).authService, 'refreshAuthenticationToken').and.returnValue(observableThrow(new Error('Message Error test'))); + + actions = hot('--a-', { + a: { + type: AuthActionTypes.RETRIEVE_TOKEN + } + }); + + const expected = cold('--b-', { b: new AuthenticationErrorAction(new Error('Message Error test')) }); + + expect(authEffects.retrieveToken$).toBeObservable(expected); + }); + }); + }); + describe('logOut$', () => { describe('when refresh token succeeded', () => { @@ -257,4 +338,29 @@ describe('AuthEffects', () => { }); }) }); + + describe('retrieveMethods$', () => { + + describe('when retrieve authentication methods succeeded', () => { + it('should return a RETRIEVE_AUTH_METHODS_SUCCESS action in response to a RETRIEVE_AUTH_METHODS action', () => { + actions = hot('--a-', { a: { type: AuthActionTypes.RETRIEVE_AUTH_METHODS } }); + + const expected = cold('--b-', { b: new RetrieveAuthMethodsSuccessAction(authMethodsMock) }); + + expect(authEffects.retrieveMethods$).toBeObservable(expected); + }); + }); + + describe('when retrieve authentication methods failed', () => { + it('should return a RETRIEVE_AUTH_METHODS_ERROR action in response to a RETRIEVE_AUTH_METHODS action', () => { + spyOn((authEffects as any).authService, 'retrieveAuthMethodsFromAuthStatus').and.returnValue(observableThrow('')); + + actions = hot('--a-', { a: { type: AuthActionTypes.RETRIEVE_AUTH_METHODS } }); + + const expected = cold('--b-', { b: new RetrieveAuthMethodsErrorAction() }); + + expect(authEffects.retrieveMethods$).toBeObservable(expected); + }); + }) + }); }); diff --git a/src/app/core/auth/auth.effects.ts b/src/app/core/auth/auth.effects.ts index 5ee63ccd92..d153748fb9 100644 --- a/src/app/core/auth/auth.effects.ts +++ b/src/app/core/auth/auth.effects.ts @@ -1,6 +1,6 @@ -import { of as observableOf, Observable } from 'rxjs'; +import { Observable, of as observableOf } from 'rxjs'; -import { filter, debounceTime, switchMap, take, tap, catchError, map } from 'rxjs/operators'; +import { catchError, debounceTime, filter, map, switchMap, take, tap } from 'rxjs/operators'; import { Injectable } from '@angular/core'; // import @ngrx @@ -9,6 +9,14 @@ import { Action, select, Store } from '@ngrx/store'; // import services import { AuthService } from './auth.service'; + +import { EPerson } from '../eperson/models/eperson.model'; +import { AuthStatus } from './models/auth-status.model'; +import { AuthTokenInfo } from './models/auth-token-info.model'; +import { AppState } from '../../app.reducer'; +import { isAuthenticated } from './selectors'; +import { StoreActionTypes } from '../../store.actions'; +import { AuthMethod } from './models/auth.method'; // import actions import { AuthActionTypes, @@ -18,7 +26,7 @@ import { AuthenticatedSuccessAction, AuthenticationErrorAction, AuthenticationSuccessAction, - CheckAuthenticationTokenErrorAction, + CheckAuthenticationTokenCookieAction, LogOutErrorAction, LogOutSuccessAction, RefreshTokenAction, @@ -29,14 +37,12 @@ import { RegistrationSuccessAction, RetrieveAuthenticatedEpersonAction, RetrieveAuthenticatedEpersonErrorAction, - RetrieveAuthenticatedEpersonSuccessAction + RetrieveAuthenticatedEpersonSuccessAction, + RetrieveAuthMethodsAction, + RetrieveAuthMethodsErrorAction, + RetrieveAuthMethodsSuccessAction, + RetrieveTokenAction } from './auth.actions'; -import { EPerson } from '../eperson/models/eperson.model'; -import { AuthStatus } from './models/auth-status.model'; -import { AuthTokenInfo } from './models/auth-token-info.model'; -import { AppState } from '../../app.reducer'; -import { isAuthenticated } from './selectors'; -import { StoreActionTypes } from '../../store.actions'; @Injectable() export class AuthEffects { @@ -47,45 +53,45 @@ export class AuthEffects { */ @Effect() public authenticate$: Observable = this.actions$.pipe( - ofType(AuthActionTypes.AUTHENTICATE), - switchMap((action: AuthenticateAction) => { - return this.authService.authenticate(action.payload.email, action.payload.password).pipe( - take(1), - map((response: AuthStatus) => new AuthenticationSuccessAction(response.token)), - catchError((error) => observableOf(new AuthenticationErrorAction(error))) - ); - }) - ); + ofType(AuthActionTypes.AUTHENTICATE), + switchMap((action: AuthenticateAction) => { + return this.authService.authenticate(action.payload.email, action.payload.password).pipe( + take(1), + map((response: AuthStatus) => new AuthenticationSuccessAction(response.token)), + catchError((error) => observableOf(new AuthenticationErrorAction(error))) + ); + }) + ); @Effect() public authenticateSuccess$: Observable = this.actions$.pipe( - ofType(AuthActionTypes.AUTHENTICATE_SUCCESS), - tap((action: AuthenticationSuccessAction) => this.authService.storeToken(action.payload)), - map((action: AuthenticationSuccessAction) => new AuthenticatedAction(action.payload)) - ); + ofType(AuthActionTypes.AUTHENTICATE_SUCCESS), + tap((action: AuthenticationSuccessAction) => this.authService.storeToken(action.payload)), + map((action: AuthenticationSuccessAction) => new AuthenticatedAction(action.payload)) + ); @Effect() public authenticated$: Observable = this.actions$.pipe( - ofType(AuthActionTypes.AUTHENTICATED), - switchMap((action: AuthenticatedAction) => { - return this.authService.authenticatedUser(action.payload).pipe( - map((userHref: string) => new AuthenticatedSuccessAction((userHref !== null), action.payload, userHref)), - catchError((error) => observableOf(new AuthenticatedErrorAction(error))),); - }) - ); + ofType(AuthActionTypes.AUTHENTICATED), + switchMap((action: AuthenticatedAction) => { + return this.authService.authenticatedUser(action.payload).pipe( + map((userHref: string) => new AuthenticatedSuccessAction((userHref !== null), action.payload, userHref)), + catchError((error) => observableOf(new AuthenticatedErrorAction(error))),); + }) + ); @Effect() public authenticatedSuccess$: Observable = this.actions$.pipe( - ofType(AuthActionTypes.AUTHENTICATED_SUCCESS), - map((action: AuthenticatedSuccessAction) => new RetrieveAuthenticatedEpersonAction(action.payload.userHref)) - ); + ofType(AuthActionTypes.AUTHENTICATED_SUCCESS), + map((action: AuthenticatedSuccessAction) => new RetrieveAuthenticatedEpersonAction(action.payload.userHref)) + ); // It means "reacts to this action but don't send another" @Effect({ dispatch: false }) public authenticatedError$: Observable = this.actions$.pipe( - ofType(AuthActionTypes.AUTHENTICATED_ERROR), - tap((action: LogOutSuccessAction) => this.authService.removeToken()) - ); + ofType(AuthActionTypes.AUTHENTICATED_ERROR), + tap((action: LogOutSuccessAction) => this.authService.removeToken()) + ); @Effect() public retrieveAuthenticatedEperson$: Observable = this.actions$.pipe( @@ -99,42 +105,71 @@ export class AuthEffects { @Effect() public checkToken$: Observable = this.actions$.pipe(ofType(AuthActionTypes.CHECK_AUTHENTICATION_TOKEN), - switchMap(() => { - return this.authService.hasValidAuthenticationToken().pipe( - map((token: AuthTokenInfo) => new AuthenticatedAction(token)), - catchError((error) => observableOf(new CheckAuthenticationTokenErrorAction())) - ); - }) - ); + switchMap(() => { + return this.authService.hasValidAuthenticationToken().pipe( + map((token: AuthTokenInfo) => new AuthenticatedAction(token)), + catchError((error) => observableOf(new CheckAuthenticationTokenCookieAction())) + ); + }) + ); + + @Effect() + public checkTokenCookie$: Observable = this.actions$.pipe( + ofType(AuthActionTypes.CHECK_AUTHENTICATION_TOKEN_COOKIE), + switchMap(() => { + return this.authService.checkAuthenticationCookie().pipe( + map((response: AuthStatus) => { + if (response.authenticated) { + return new RetrieveTokenAction(); + } else { + return new RetrieveAuthMethodsAction(response); + } + }), + catchError((error) => observableOf(new AuthenticatedErrorAction(error))) + ); + }) + ); @Effect() public createUser$: Observable = this.actions$.pipe( - ofType(AuthActionTypes.REGISTRATION), - debounceTime(500), // to remove when functionality is implemented - switchMap((action: RegistrationAction) => { - return this.authService.create(action.payload).pipe( - map((user: EPerson) => new RegistrationSuccessAction(user)), - catchError((error) => observableOf(new RegistrationErrorAction(error))) - ); - }) - ); + ofType(AuthActionTypes.REGISTRATION), + debounceTime(500), // to remove when functionality is implemented + switchMap((action: RegistrationAction) => { + return this.authService.create(action.payload).pipe( + map((user: EPerson) => new RegistrationSuccessAction(user)), + catchError((error) => observableOf(new RegistrationErrorAction(error))) + ); + }) + ); + + @Effect() + public retrieveToken$: Observable = this.actions$.pipe( + ofType(AuthActionTypes.RETRIEVE_TOKEN), + switchMap((action: AuthenticateAction) => { + return this.authService.refreshAuthenticationToken(null).pipe( + take(1), + map((token: AuthTokenInfo) => new AuthenticationSuccessAction(token)), + catchError((error) => observableOf(new AuthenticationErrorAction(error))) + ); + }) + ); @Effect() public refreshToken$: Observable = this.actions$.pipe(ofType(AuthActionTypes.REFRESH_TOKEN), - switchMap((action: RefreshTokenAction) => { - return this.authService.refreshAuthenticationToken(action.payload).pipe( - map((token: AuthTokenInfo) => new RefreshTokenSuccessAction(token)), - catchError((error) => observableOf(new RefreshTokenErrorAction())) - ); - }) - ); + switchMap((action: RefreshTokenAction) => { + return this.authService.refreshAuthenticationToken(action.payload).pipe( + map((token: AuthTokenInfo) => new RefreshTokenSuccessAction(token)), + catchError((error) => observableOf(new RefreshTokenErrorAction())) + ); + }) + ); // It means "reacts to this action but don't send another" @Effect({ dispatch: false }) public refreshTokenSuccess$: Observable = this.actions$.pipe( - ofType(AuthActionTypes.REFRESH_TOKEN_SUCCESS), - tap((action: RefreshTokenSuccessAction) => this.authService.replaceToken(action.payload)) - ); + ofType(AuthActionTypes.REFRESH_TOKEN_SUCCESS), + tap((action: RefreshTokenSuccessAction) => this.authService.replaceToken(action.payload)) + ); /** * When the store is rehydrated in the browser, @@ -188,6 +223,19 @@ export class AuthEffects { tap(() => this.authService.redirectToLoginWhenTokenExpired()) ); + @Effect() + public retrieveMethods$: Observable = this.actions$ + .pipe( + ofType(AuthActionTypes.RETRIEVE_AUTH_METHODS), + switchMap((action: RetrieveAuthMethodsAction) => { + return this.authService.retrieveAuthMethodsFromAuthStatus(action.payload) + .pipe( + map((authMethodModels: AuthMethod[]) => new RetrieveAuthMethodsSuccessAction(authMethodModels)), + catchError((error) => observableOf(new RetrieveAuthMethodsErrorAction())) + ) + }) + ); + /** * @constructor * @param {Actions} actions$ diff --git a/src/app/core/auth/auth.interceptor.ts b/src/app/core/auth/auth.interceptor.ts index 08e892bbd9..6d609a4ea3 100644 --- a/src/app/core/auth/auth.interceptor.ts +++ b/src/app/core/auth/auth.interceptor.ts @@ -6,6 +6,7 @@ import { HttpErrorResponse, HttpEvent, HttpHandler, + HttpHeaders, HttpInterceptor, HttpRequest, HttpResponse, @@ -17,10 +18,12 @@ 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, isUndefined, isNotNull } from '../../shared/empty.util'; +import { isNotEmpty, isNotNull, isUndefined } from '../../shared/empty.util'; import { RedirectWhenTokenExpiredAction, RefreshTokenAction } from './auth.actions'; import { Store } from '@ngrx/store'; import { Router } from '@angular/router'; +import { AuthMethod } from './models/auth.method'; +import { AuthMethodType } from './models/auth.method-type'; @Injectable() export class AuthInterceptor implements HttpInterceptor { @@ -30,17 +33,33 @@ export class AuthInterceptor implements HttpInterceptor { // we're creating a refresh token request list protected refreshTokenRequestUrls = []; - constructor(private inj: Injector, private router: Router, private store: Store) { } + constructor(private inj: Injector, private router: Router, private store: Store) { + } + /** + * Check if response status code is 401 + * + * @param response + */ private isUnauthorized(response: HttpResponseBase): boolean { // invalid_token The access token provided is expired, revoked, malformed, or invalid for other reasons return response.status === 401; } + /** + * Check if response status code is 200 or 204 + * + * @param response + */ private isSuccess(response: HttpResponseBase): boolean { return (response.status === 200 || response.status === 204); } + /** + * Check if http request is to authn endpoint + * + * @param http + */ private isAuthRequest(http: HttpRequest | HttpResponseBase): boolean { return http && http.url && (http.url.endsWith('/authn/login') @@ -48,18 +67,131 @@ export class AuthInterceptor implements HttpInterceptor { || http.url.endsWith('/authn/status')); } + /** + * Check if response is from a login request + * + * @param http + */ private isLoginResponse(http: HttpRequest | HttpResponseBase): boolean { - return http.url && http.url.endsWith('/authn/login'); + return http.url && http.url.endsWith('/authn/login') } + /** + * Check if response is from a logout request + * + * @param http + */ private isLogoutResponse(http: HttpRequest | HttpResponseBase): boolean { return http.url && http.url.endsWith('/authn/logout'); } - private makeAuthStatusObject(authenticated: boolean, accessToken?: string, error?: string): AuthStatus { + /** + * Check if response is from a status request + * + * @param http + */ + private isStatusResponse(http: HttpRequest | HttpResponseBase): boolean { + return http.url && http.url.endsWith('/authn/status'); + } + + /** + * Extract location url from the WWW-Authenticate header + * + * @param header + */ + private parseLocation(header: string): string { + let location = header.trim(); + location = location.replace('location="', ''); + location = location.replace('"', ''); + let re = /%3A%2F%2F/g; + location = location.replace(re, '://'); + re = /%3A/g; + location = location.replace(re, ':'); + return location.trim(); + } + + /** + * Sort authentication methods list + * + * @param authMethodModels + */ + private sortAuthMethods(authMethodModels: AuthMethod[]): AuthMethod[] { + const sortedAuthMethodModels: AuthMethod[] = []; + authMethodModels.forEach((method) => { + if (method.authMethodType === AuthMethodType.Password) { + sortedAuthMethodModels.push(method); + } + }); + + authMethodModels.forEach((method) => { + if (method.authMethodType !== AuthMethodType.Password) { + sortedAuthMethodModels.push(method); + } + }); + + return sortedAuthMethodModels; + } + + /** + * Extract authentication methods list from the WWW-Authenticate headers + * + * @param headers + */ + private parseAuthMethodsFromHeaders(headers: HttpHeaders): AuthMethod[] { + let authMethodModels: AuthMethod[] = []; + if (isNotEmpty(headers.get('www-authenticate'))) { + // get the realms from the header - a realm is a single auth method + const completeWWWauthenticateHeader = headers.get('www-authenticate'); + const regex = /(\w+ (\w+=((".*?")|[^,]*)(, )?)*)/g; + const realms = completeWWWauthenticateHeader.match(regex); + + // tslint:disable-next-line:forin + for (const j in realms) { + + const splittedRealm = realms[j].split(', '); + const methodName = splittedRealm[0].split(' ')[0].trim(); + + let authMethodModel: AuthMethod; + if (splittedRealm.length === 1) { + authMethodModel = new AuthMethod(methodName); + authMethodModels.push(authMethodModel); + } else if (splittedRealm.length > 1) { + let location = splittedRealm[1]; + location = this.parseLocation(location); + authMethodModel = new AuthMethod(methodName, location); + authMethodModels.push(authMethodModel); + } + } + + // make sure the email + password login component gets rendered first + authMethodModels = this.sortAuthMethods(authMethodModels); + } else { + authMethodModels.push(new AuthMethod(AuthMethodType.Password)); + } + + return authMethodModels; + } + + /** + * Generate an AuthStatus object + * + * @param authenticated + * @param accessToken + * @param error + * @param httpHeaders + */ + private makeAuthStatusObject(authenticated: boolean, accessToken?: string, error?: string, httpHeaders?: HttpHeaders): AuthStatus { const authStatus = new AuthStatus(); + // let authMethods: AuthMethodModel[]; + if (httpHeaders) { + authStatus.authMethods = this.parseAuthMethodsFromHeaders(httpHeaders); + } + authStatus.id = null; + authStatus.okay = true; + // authStatus.authMethods = authMethods; + if (authenticated) { authStatus.authenticated = true; authStatus.token = new AuthTokenInfo(accessToken); @@ -70,12 +202,18 @@ export class AuthInterceptor implements HttpInterceptor { return authStatus; } + /** + * Intercept method + * @param req + * @param next + */ intercept(req: HttpRequest, next: HttpHandler): Observable> { const authService = this.inj.get(AuthService); - const token = authService.getToken(); - let newReq; + const token: AuthTokenInfo = authService.getToken(); + let newReq: HttpRequest; + let authorization: string; if (authService.isTokenExpired()) { authService.setRedirectUrl(this.router.url); @@ -96,30 +234,41 @@ export class AuthInterceptor implements HttpInterceptor { } }); // Get the auth header from the service. - const Authorization = authService.buildAuthHeader(token); + authorization = authService.buildAuthHeader(token); // Clone the request to add the new header. - newReq = req.clone({headers: req.headers.set('authorization', Authorization)}); + newReq = req.clone({ headers: req.headers.set('authorization', authorization) }); } else { - newReq = req; + newReq = req.clone(); } // Pass on the new request instead of the original request. return next.handle(newReq).pipe( + // tap((response) => console.log('next.handle: ', response)), map((response) => { // Intercept a Login/Logout response - if (response instanceof HttpResponse && this.isSuccess(response) && (this.isLoginResponse(response) || this.isLogoutResponse(response))) { + if (response instanceof HttpResponse && this.isSuccess(response) && this.isAuthRequest(response)) { // It's a success Login/Logout response let authRes: HttpResponse; if (this.isLoginResponse(response)) { // login successfully const newToken = response.headers.get('authorization'); - authRes = response.clone({body: this.makeAuthStatusObject(true, newToken)}); + authRes = response.clone({ + body: this.makeAuthStatusObject(true, newToken) + }); // clean eventually refresh Requests list this.refreshTokenRequestUrls = []; + } else if (this.isStatusResponse(response)) { + authRes = response.clone({ + body: Object.assign(response.body, { + authMethods: this.parseAuthMethodsFromHeaders(response.headers) + }) + }) } else { // logout successfully - authRes = response.clone({body: this.makeAuthStatusObject(false)}); + authRes = response.clone({ + body: this.makeAuthStatusObject(false) + }); } return authRes; } else { @@ -129,13 +278,15 @@ export class AuthInterceptor implements HttpInterceptor { catchError((error, caught) => { // Intercept an error response if (error instanceof HttpErrorResponse) { + // Checks if is a response from a request to an authentication endpoint if (this.isAuthRequest(error)) { // clean eventually refresh Requests list this.refreshTokenRequestUrls = []; + // Create a new HttpResponse and return it, so it can be handle properly by AuthService. const authResponse = new HttpResponse({ - body: this.makeAuthStatusObject(false, null, error.error), + body: this.makeAuthStatusObject(false, null, error.error, error.headers), headers: error.headers, status: error.status, statusText: error.statusText, diff --git a/src/app/core/auth/auth.reducer.spec.ts b/src/app/core/auth/auth.reducer.spec.ts index af1b6626d0..7a39ef3da4 100644 --- a/src/app/core/auth/auth.reducer.spec.ts +++ b/src/app/core/auth/auth.reducer.spec.ts @@ -8,7 +8,7 @@ import { AuthenticationErrorAction, AuthenticationSuccessAction, CheckAuthenticationTokenAction, - CheckAuthenticationTokenErrorAction, + CheckAuthenticationTokenCookieAction, LogOutAction, LogOutErrorAction, LogOutSuccessAction, @@ -17,11 +17,19 @@ import { RefreshTokenAction, RefreshTokenErrorAction, RefreshTokenSuccessAction, - ResetAuthenticationMessagesAction, RetrieveAuthenticatedEpersonErrorAction, RetrieveAuthenticatedEpersonSuccessAction, + ResetAuthenticationMessagesAction, + RetrieveAuthenticatedEpersonErrorAction, + RetrieveAuthenticatedEpersonSuccessAction, + RetrieveAuthMethodsAction, + RetrieveAuthMethodsErrorAction, + RetrieveAuthMethodsSuccessAction, SetRedirectUrlAction } from './auth.actions'; import { AuthTokenInfo } from './models/auth-token-info.model'; -import { EPersonMock } from '../../shared/testing/eperson.mock'; +import { EPersonMock } from '../../shared/testing/eperson-mock'; +import { AuthStatus } from './models/auth-status.model'; +import { AuthMethod } from './models/auth.method'; +import { AuthMethodType } from './models/auth.method-type'; describe('authReducer', () => { @@ -157,18 +165,18 @@ describe('authReducer', () => { expect(newState).toEqual(state); }); - it('should properly set the state, in response to a CHECK_AUTHENTICATION_TOKEN_ERROR action', () => { + it('should properly set the state, in response to a CHECK_AUTHENTICATION_TOKEN_COOKIE action', () => { initialState = { authenticated: false, loaded: false, loading: true, }; - const action = new CheckAuthenticationTokenErrorAction(); + const action = new CheckAuthenticationTokenCookieAction(); const newState = authReducer(initialState, action); state = { authenticated: false, loaded: false, - loading: false, + loading: true, }; expect(newState).toEqual(state); }); @@ -451,4 +459,63 @@ describe('authReducer', () => { }; expect(newState).toEqual(state); }); + + it('should properly set the state, in response to a RETRIEVE_AUTH_METHODS action', () => { + initialState = { + authenticated: false, + loaded: false, + loading: false, + authMethods: [] + }; + const action = new RetrieveAuthMethodsAction(new AuthStatus()); + const newState = authReducer(initialState, action); + state = { + authenticated: false, + loaded: false, + loading: true, + authMethods: [] + }; + expect(newState).toEqual(state); + }); + + it('should properly set the state, in response to a RETRIEVE_AUTH_METHODS_SUCCESS action', () => { + initialState = { + authenticated: false, + loaded: false, + loading: true, + authMethods: [] + }; + const authMethods = [ + new AuthMethod(AuthMethodType.Password), + new AuthMethod(AuthMethodType.Shibboleth, 'location') + ]; + const action = new RetrieveAuthMethodsSuccessAction(authMethods); + const newState = authReducer(initialState, action); + state = { + authenticated: false, + loaded: false, + loading: false, + authMethods: authMethods + }; + expect(newState).toEqual(state); + }); + + it('should properly set the state, in response to a RETRIEVE_AUTH_METHODS_ERROR action', () => { + initialState = { + authenticated: false, + loaded: false, + loading: true, + authMethods: [] + }; + + const action = new RetrieveAuthMethodsErrorAction(); + const newState = authReducer(initialState, action); + state = { + authenticated: false, + loaded: false, + loading: false, + authMethods: [new AuthMethod(AuthMethodType.Password)] + }; + expect(newState).toEqual(state); + }); }); diff --git a/src/app/core/auth/auth.reducer.ts b/src/app/core/auth/auth.reducer.ts index 7d5e50c432..19fd162d3f 100644 --- a/src/app/core/auth/auth.reducer.ts +++ b/src/app/core/auth/auth.reducer.ts @@ -8,12 +8,16 @@ import { LogOutErrorAction, RedirectWhenAuthenticationIsRequiredAction, RedirectWhenTokenExpiredAction, - RefreshTokenSuccessAction, RetrieveAuthenticatedEpersonSuccessAction, + RefreshTokenSuccessAction, + RetrieveAuthenticatedEpersonSuccessAction, + RetrieveAuthMethodsSuccessAction, 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'; /** * The auth state. @@ -47,6 +51,10 @@ export interface AuthState { // the authenticated user user?: EPerson; + + // all authentication Methods enabled at the backend + authMethods?: AuthMethod[]; + } /** @@ -56,6 +64,7 @@ const initialState: AuthState = { authenticated: false, loaded: false, loading: false, + authMethods: [] }; /** @@ -75,6 +84,8 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut }); case AuthActionTypes.AUTHENTICATED: + case AuthActionTypes.CHECK_AUTHENTICATION_TOKEN: + case AuthActionTypes.CHECK_AUTHENTICATION_TOKEN_COOKIE: return Object.assign({}, state, { loading: true }); @@ -113,21 +124,10 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut loading: false }); - case AuthActionTypes.AUTHENTICATED: case AuthActionTypes.AUTHENTICATE_SUCCESS: case AuthActionTypes.LOG_OUT: return state; - case AuthActionTypes.CHECK_AUTHENTICATION_TOKEN: - return Object.assign({}, state, { - loading: true - }); - - case AuthActionTypes.CHECK_AUTHENTICATION_TOKEN_ERROR: - return Object.assign({}, state, { - loading: false - }); - case AuthActionTypes.LOG_OUT_ERROR: return Object.assign({}, state, { authenticated: true, @@ -192,6 +192,24 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut info: undefined, }); + // next three cases are used by dynamic rendering of login methods + case AuthActionTypes.RETRIEVE_AUTH_METHODS: + return Object.assign({}, state, { + loading: true + }); + + case AuthActionTypes.RETRIEVE_AUTH_METHODS_SUCCESS: + return Object.assign({}, state, { + loading: false, + authMethods: (action as RetrieveAuthMethodsSuccessAction).payload + }); + + case AuthActionTypes.RETRIEVE_AUTH_METHODS_ERROR: + return Object.assign({}, state, { + loading: false, + authMethods: [new AuthMethod(AuthMethodType.Password)] + }); + case AuthActionTypes.SET_REDIRECT_URL: return Object.assign({}, state, { redirectUrl: (action as SetRedirectUrlAction).payload, diff --git a/src/app/core/auth/auth.service.spec.ts b/src/app/core/auth/auth.service.spec.ts index d29902eedb..89d8cdce4e 100644 --- a/src/app/core/auth/auth.service.spec.ts +++ b/src/app/core/auth/auth.service.spec.ts @@ -28,6 +28,8 @@ import { Observable } from 'rxjs/internal/Observable'; import { RemoteData } from '../data/remote-data'; import { EPersonDataService } from '../eperson/eperson-data.service'; import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; +import { authMethodsMock } from '../../shared/testing/auth-service.stub'; +import { AuthMethod } from './models/auth.method'; describe('AuthService test', () => { @@ -150,6 +152,26 @@ describe('AuthService test', () => { expect(authService.logout.bind(null)).toThrow(); }); + it('should return the authentication status object to check an Authentication Cookie', () => { + authService.checkAuthenticationCookie().subscribe((status: AuthStatus) => { + expect(status).toBeDefined(); + }); + }); + + it('should return the authentication methods available', () => { + const authStatus = new AuthStatus(); + + authService.retrieveAuthMethodsFromAuthStatus(authStatus).subscribe((authMethods: AuthMethod[]) => { + expect(authMethods).toBeDefined(); + expect(authMethods.length).toBe(0); + }); + + authStatus.authMethods = authMethodsMock; + authService.retrieveAuthMethodsFromAuthStatus(authStatus).subscribe((authMethods: AuthMethod[]) => { + expect(authMethods).toBeDefined(); + expect(authMethods.length).toBe(2); + }); + }); }); describe('', () => { diff --git a/src/app/core/auth/auth.service.ts b/src/app/core/auth/auth.service.ts index f8847b0b2e..0f5c06bbc9 100644 --- a/src/app/core/auth/auth.service.ts +++ b/src/app/core/auth/auth.service.ts @@ -18,16 +18,20 @@ import { isEmpty, isNotEmpty, isNotNull, isNotUndefined } from '../../shared/emp import { CookieService } from '../services/cookie.service'; import { getAuthenticationToken, getRedirectUrl, isAuthenticated, isTokenRefreshing } from './selectors'; import { AppState, routerStateSelector } from '../../app.reducer'; -import { ResetAuthenticationMessagesAction, SetRedirectUrlAction } from './auth.actions'; +import { + CheckAuthenticationTokenAction, + ResetAuthenticationMessagesAction, + SetRedirectUrlAction +} from './auth.actions'; import { NativeWindowRef, NativeWindowService } from '../services/window.service'; import { Base64EncodeUrl } from '../../shared/utils/encode-decode.util'; import { RouteService } from '../services/route.service'; import { EPersonDataService } from '../eperson/eperson-data.service'; -import { getAllSucceededRemoteDataPayload, getFirstSucceededRemoteDataPayload } from '../shared/operators'; +import { getAllSucceededRemoteDataPayload } from '../shared/operators'; +import { AuthMethod } from './models/auth.method'; export const LOGIN_ROUTE = '/login'; export const LOGOUT_ROUTE = '/logout'; - export const REDIRECT_COOKIE = 'dsRedirectUrl'; /** @@ -114,6 +118,21 @@ export class AuthService { } + /** + * Checks if token is present into the request cookie + */ + public checkAuthenticationCookie(): Observable { + // Determine if the user has an existing auth session on the server + const options: HttpOptions = Object.create({}); + let headers = new HttpHeaders(); + headers = headers.append('Accept', 'application/json'); + options.headers = headers; + options.withCredentials = true; + return this.authRequestService.getRequest('status', options).pipe( + map((status: AuthStatus) => Object.assign(new AuthStatus(), status)) + ); + } + /** * Determines if the user is authenticated * @returns {Observable} @@ -154,10 +173,10 @@ export class AuthService { } /** - * Checks if token is present into browser storage and is valid. (NB Check is done only on SSR) + * Checks if token is present into browser storage and is valid. */ public checkAuthenticationToken() { - return + this.store.dispatch(new CheckAuthenticationTokenAction()); } /** @@ -187,8 +206,11 @@ export class AuthService { const options: HttpOptions = Object.create({}); let headers = new HttpHeaders(); headers = headers.append('Accept', 'application/json'); - headers = headers.append('Authorization', `Bearer ${token.accessToken}`); + if (token && token.accessToken) { + headers = headers.append('Authorization', `Bearer ${token.accessToken}`); + } options.headers = headers; + options.withCredentials = true; return this.authRequestService.postToEndpoint('login', {}, options).pipe( map((status: AuthStatus) => { if (status.authenticated) { @@ -206,6 +228,18 @@ export class AuthService { this.store.dispatch(new ResetAuthenticationMessagesAction()); } + /** + * Retrieve authentication methods available + * @returns {User} + */ + public retrieveAuthMethodsFromAuthStatus(status: AuthStatus): Observable { + let authMethods: AuthMethod[] = []; + if (isNotEmpty(status.authMethods)) { + authMethods = status.authMethods; + } + return observableOf(authMethods); + } + /** * Create a new user * @returns {User} diff --git a/src/app/core/auth/authenticated.guard.ts b/src/app/core/auth/authenticated.guard.ts index af0622cd19..7a2f39854c 100644 --- a/src/app/core/auth/authenticated.guard.ts +++ b/src/app/core/auth/authenticated.guard.ts @@ -1,17 +1,14 @@ - -import {take} from 'rxjs/operators'; import { Injectable } from '@angular/core'; import { ActivatedRouteSnapshot, CanActivate, CanLoad, Route, Router, RouterStateSnapshot } from '@angular/router'; -import {Observable, of} from 'rxjs'; +import { Observable } from 'rxjs'; +import { take } from 'rxjs/operators'; import { select, Store } from '@ngrx/store'; -// reducers import { CoreState } from '../core.reducers'; -import { isAuthenticated, isAuthenticationLoading } from './selectors'; +import { isAuthenticated } from './selectors'; import { AuthService } from './auth.service'; import { RedirectWhenAuthenticationIsRequiredAction } from './auth.actions'; -import { isEmpty } from '../../shared/empty.util'; /** * Prevent unauthorized activating and loading of routes diff --git a/src/app/core/auth/models/auth-status.model.ts b/src/app/core/auth/models/auth-status.model.ts index edad46a7bc..197c025407 100644 --- a/src/app/core/auth/models/auth-status.model.ts +++ b/src/app/core/auth/models/auth-status.model.ts @@ -12,6 +12,7 @@ import { excludeFromEquals } from '../../utilities/equals.decorators'; import { AuthError } from './auth-error.model'; import { AUTH_STATUS } from './auth-status.resource-type'; import { AuthTokenInfo } from './auth-token-info.model'; +import { AuthMethod } from './auth.method'; /** * Object that represents the authenticated status of a user @@ -79,5 +80,13 @@ export class AuthStatus implements CacheableObject { * Authentication error if there was one for this status */ // TODO should be refactored to use the RemoteData error + @autoserialize error?: AuthError; + + /** + * All authentication methods enabled at the backend + */ + @autoserialize + authMethods: AuthMethod[]; + } diff --git a/src/app/core/auth/models/auth.method-type.ts b/src/app/core/auth/models/auth.method-type.ts new file mode 100644 index 0000000000..f053515065 --- /dev/null +++ b/src/app/core/auth/models/auth.method-type.ts @@ -0,0 +1,7 @@ +export enum AuthMethodType { + Password = 'password', + Shibboleth = 'shibboleth', + Ldap = 'ldap', + Ip = 'ip', + X509 = 'x509' +} diff --git a/src/app/core/auth/models/auth.method.ts b/src/app/core/auth/models/auth.method.ts new file mode 100644 index 0000000000..617154080b --- /dev/null +++ b/src/app/core/auth/models/auth.method.ts @@ -0,0 +1,38 @@ +import { AuthMethodType } from './auth.method-type'; + +export class AuthMethod { + authMethodType: AuthMethodType; + location?: string; + + // isStandalonePage? = true; + + constructor(authMethodName: string, location?: string) { + switch (authMethodName) { + case 'ip': { + this.authMethodType = AuthMethodType.Ip; + break; + } + case 'ldap': { + this.authMethodType = AuthMethodType.Ldap; + break; + } + case 'shibboleth': { + this.authMethodType = AuthMethodType.Shibboleth; + this.location = location; + break; + } + case 'x509': { + this.authMethodType = AuthMethodType.X509; + break; + } + case 'password': { + this.authMethodType = AuthMethodType.Password; + break; + } + + default: { + break; + } + } + } +} diff --git a/src/app/core/auth/models/normalized-auth-status.model.ts b/src/app/core/auth/models/normalized-auth-status.model.ts new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/core/auth/selectors.ts b/src/app/core/auth/selectors.ts index 8c88e0fce5..4e51bc1fc9 100644 --- a/src/app/core/auth/selectors.ts +++ b/src/app/core/auth/selectors.ts @@ -107,6 +107,17 @@ const _getRegistrationError = (state: AuthState) => state.error; */ const _getRedirectUrl = (state: AuthState) => state.redirectUrl; +const _getAuthenticationMethods = (state: AuthState) => state.authMethods; + +/** + * Returns the authentication methods enabled at the backend + * @function getAuthenticationMethods + * @param {AuthState} state + * @param {any} props + * @return {any} + */ +export const getAuthenticationMethods = createSelector(getAuthState, _getAuthenticationMethods); + /** * Returns the authenticated user * @function getAuthenticatedUser diff --git a/src/app/core/auth/server-auth.service.ts b/src/app/core/auth/server-auth.service.ts index c8cba0206b..30767be85a 100644 --- a/src/app/core/auth/server-auth.service.ts +++ b/src/app/core/auth/server-auth.service.ts @@ -1,11 +1,11 @@ -import { HttpHeaders } from '@angular/common/http'; import { Injectable } from '@angular/core'; +import { HttpHeaders } from '@angular/common/http'; import { Observable } from 'rxjs'; import { filter, map, take } from 'rxjs/operators'; + import { isNotEmpty } from '../../shared/empty.util'; import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service'; -import { CheckAuthenticationTokenAction } from './auth.actions'; import { AuthService } from './auth.service'; import { AuthStatus } from './models/auth-status.model'; import { AuthTokenInfo } from './models/auth-token-info.model'; @@ -43,10 +43,23 @@ export class ServerAuthService extends AuthService { } /** - * Checks if token is present into browser storage and is valid. (NB Check is done only on SSR) + * Checks if token is present into the request cookie */ - public checkAuthenticationToken() { - this.store.dispatch(new CheckAuthenticationTokenAction()) + public checkAuthenticationCookie(): Observable { + // Determine if the user has an existing auth session on the server + const options: HttpOptions = Object.create({}); + let headers = new HttpHeaders(); + headers = headers.append('Accept', 'application/json'); + if (isNotEmpty(this.req.protocol) && isNotEmpty(this.req.header('host'))) { + const referer = this.req.protocol + '://' + this.req.header('host') + this.req.path; + // use to allow the rest server to identify the real origin on SSR + headers = headers.append('X-Requested-With', referer); + } + options.headers = headers; + options.withCredentials = true; + return this.authRequestService.getRequest('status', options).pipe( + map((status: AuthStatus) => Object.assign(new AuthStatus(), status)) + ); } /** diff --git a/src/app/core/core.module.ts b/src/app/core/core.module.ts index 30a5f023ae..01a858113d 100644 --- a/src/app/core/core.module.ts +++ b/src/app/core/core.module.ts @@ -1,11 +1,8 @@ import { CommonModule } from '@angular/common'; import { HTTP_INTERCEPTORS, HttpClient } from '@angular/common/http'; import { ModuleWithProviders, NgModule, Optional, SkipSelf } from '@angular/core'; -import { - DynamicFormLayoutService, - DynamicFormService, - DynamicFormValidationService -} from '@ng-dynamic-forms/core'; + +import { DynamicFormLayoutService, DynamicFormService, DynamicFormValidationService } from '@ng-dynamic-forms/core'; import { EffectsModule } from '@ngrx/effects'; import { Action, StoreConfig, StoreModule } from '@ngrx/store'; @@ -17,7 +14,6 @@ import { FormService } from '../shared/form/form.service'; import { HostWindowService } from '../shared/host-window.service'; import { MenuService } from '../shared/menu/menu.service'; import { EndpointMockingRestService } from '../shared/mocks/dspace-rest-v2/endpoint-mocking-rest.service'; - import { MOCK_RESPONSE_MAP, ResponseMapMock, @@ -47,7 +43,6 @@ import { SubmissionUploadsModel } from './config/models/config-submission-upload import { SubmissionDefinitionsConfigService } from './config/submission-definitions-config.service'; import { SubmissionFormsConfigService } from './config/submission-forms-config.service'; import { SubmissionSectionsConfigService } from './config/submission-sections-config.service'; - import { coreEffects } from './core.effects'; import { coreReducers, CoreState } from './core.reducers'; import { BitstreamFormatDataService } from './data/bitstream-format-data.service'; @@ -102,7 +97,6 @@ import { RegistryService } from './registry/registry.service'; import { RoleService } from './roles/role.service'; import { ApiService } from './services/api.service'; -import { RouteService } from './services/route.service'; import { ServerResponseService } from './services/server-response.service'; import { NativeWindowFactory, NativeWindowService } from './services/window.service'; import { BitstreamFormat } from './shared/bitstream-format.model'; @@ -143,6 +137,10 @@ import { PoolTaskDataService } from './tasks/pool-task-data.service'; import { TaskResponseParsingService } from './tasks/task-response-parsing.service'; import { environment } from '../../environments/environment'; import { storeModuleConfig } from '../app.reducer'; +import { VersionDataService } from './data/version-data.service'; +import { VersionHistoryDataService } from './data/version-history-data.service'; +import { Version } from './shared/version.model'; +import { VersionHistory } from './shared/version-history.model'; /** * When not in production, endpoint responses can be mocked for testing purposes @@ -212,7 +210,6 @@ const PROVIDERS = [ BrowseItemsResponseParsingService, BrowseService, ConfigResponseParsingService, - RouteService, SubmissionDefinitionsConfigService, SubmissionFormsConfigService, SubmissionRestService, @@ -256,6 +253,8 @@ const PROVIDERS = [ RelationshipTypeService, ExternalSourceService, LookupRelationService, + VersionDataService, + VersionHistoryDataService, LicenseDataService, ItemTypeDataService, // register AuthInterceptor as HttpInterceptor @@ -306,6 +305,8 @@ export const models = ItemType, ExternalSource, ExternalSourceEntry, + Version, + VersionHistory ]; @NgModule({ diff --git a/src/app/core/data/request.models.ts b/src/app/core/data/request.models.ts index e17ffcac3f..0655333502 100644 --- a/src/app/core/data/request.models.ts +++ b/src/app/core/data/request.models.ts @@ -230,6 +230,8 @@ export class AuthPostRequest extends PostRequest { } export class AuthGetRequest extends GetRequest { + forceBypassCache = true; + constructor(uuid: string, href: string, public options?: HttpOptions) { super(uuid, href, null, options); } diff --git a/src/app/core/data/version-data.service.ts b/src/app/core/data/version-data.service.ts new file mode 100644 index 0000000000..80fb4f5b80 --- /dev/null +++ b/src/app/core/data/version-data.service.ts @@ -0,0 +1,44 @@ +import { Injectable } from '@angular/core'; +import { DataService } from './data.service'; +import { Version } from '../shared/version.model'; +import { RequestService } from './request.service'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { Store } from '@ngrx/store'; +import { CoreState } from '../core.reducers'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { HttpClient } from '@angular/common/http'; +import { DefaultChangeAnalyzer } from './default-change-analyzer.service'; +import { FindListOptions } from './request.models'; +import { Observable } from 'rxjs/internal/Observable'; +import { dataService } from '../cache/builders/build-decorators'; +import { VERSION } from '../shared/version.resource-type'; + +/** + * Service responsible for handling requests related to the Version object + */ +@Injectable() +@dataService(VERSION) +export class VersionDataService extends DataService { + protected linkPath = 'versions'; + + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected store: Store, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected notificationsService: NotificationsService, + protected http: HttpClient, + protected comparator: DefaultChangeAnalyzer) { + super(); + } + + /** + * Get the endpoint for browsing versions + */ + getBrowseEndpoint(options: FindListOptions = {}, linkPath?: string): Observable { + return this.halService.getEndpoint(this.linkPath); + } +} diff --git a/src/app/core/data/version-history-data.service.spec.ts b/src/app/core/data/version-history-data.service.spec.ts new file mode 100644 index 0000000000..6728df71f1 --- /dev/null +++ b/src/app/core/data/version-history-data.service.spec.ts @@ -0,0 +1,54 @@ +import { RequestService } from './request.service'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { VersionHistoryDataService } from './version-history-data.service'; +import { NotificationsServiceStub } from '../../shared/testing/notifications-service-stub'; +import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service-stub'; +import { getMockRequestService } from '../../shared/mocks/mock-request.service'; +import { GetRequest } from './request.models'; + +const url = 'fake-url'; + +describe('VersionHistoryDataService', () => { + let service: VersionHistoryDataService; + + let requestService: RequestService; + let notificationsService: any; + let rdbService: RemoteDataBuildService; + let objectCache: ObjectCacheService; + let halService: any; + + beforeEach(() => { + createService(); + }); + + describe('getVersions', () => { + let result; + + beforeEach(() => { + result = service.getVersions('1'); + }); + + it('should configure a GET request', () => { + expect(requestService.configure).toHaveBeenCalledWith(jasmine.any(GetRequest)); + }); + }); + + /** + * Create a VersionHistoryDataService used for testing + * @param requestEntry$ Supply a requestEntry to be returned by the REST API (optional) + */ + function createService(requestEntry$?) { + requestService = getMockRequestService(requestEntry$); + rdbService = jasmine.createSpyObj('rdbService', { + buildList: jasmine.createSpy('buildList') + }); + objectCache = jasmine.createSpyObj('objectCache', { + remove: jasmine.createSpy('remove') + }); + halService = new HALEndpointServiceStub(url); + notificationsService = new NotificationsServiceStub(); + + service = new VersionHistoryDataService(requestService, rdbService, null, objectCache, halService, notificationsService, null, null); + } +}); diff --git a/src/app/core/data/version-history-data.service.ts b/src/app/core/data/version-history-data.service.ts new file mode 100644 index 0000000000..b4107d629d --- /dev/null +++ b/src/app/core/data/version-history-data.service.ts @@ -0,0 +1,81 @@ +import { DataService } from './data.service'; +import { VersionHistory } from '../shared/version-history.model'; +import { Injectable } from '@angular/core'; +import { RequestService } from './request.service'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { Store } from '@ngrx/store'; +import { CoreState } from '../core.reducers'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { HttpClient } from '@angular/common/http'; +import { DefaultChangeAnalyzer } from './default-change-analyzer.service'; +import { FindListOptions, GetRequest } from './request.models'; +import { Observable } from 'rxjs/internal/Observable'; +import { PaginatedSearchOptions } from '../../shared/search/paginated-search-options.model'; +import { RemoteData } from './remote-data'; +import { PaginatedList } from './paginated-list'; +import { Version } from '../shared/version.model'; +import { map, switchMap, take } from 'rxjs/operators'; +import { dataService } from '../cache/builders/build-decorators'; +import { VERSION_HISTORY } from '../shared/version-history.resource-type'; +import { followLink, FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; + +/** + * Service responsible for handling requests related to the VersionHistory object + */ +@Injectable() +@dataService(VERSION_HISTORY) +export class VersionHistoryDataService extends DataService { + protected linkPath = 'versionhistories'; + protected versionsEndpoint = 'versions'; + + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected store: Store, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected notificationsService: NotificationsService, + protected http: HttpClient, + protected comparator: DefaultChangeAnalyzer) { + super(); + } + + /** + * Get the endpoint for browsing versions + */ + getBrowseEndpoint(options: FindListOptions = {}, linkPath?: string): Observable { + return this.halService.getEndpoint(this.linkPath); + } + + /** + * Get the versions endpoint for a version history + * @param versionHistoryId + */ + getVersionsEndpoint(versionHistoryId: string): Observable { + return this.getBrowseEndpoint().pipe( + switchMap((href: string) => this.halService.getEndpoint(this.versionsEndpoint, `${href}/${versionHistoryId}`)) + ); + } + + /** + * Get a version history's versions using paginated search options + * @param versionHistoryId The version history's ID + * @param searchOptions The search options to use + * @param linksToFollow HAL Links to follow on the Versions + */ + getVersions(versionHistoryId: string, searchOptions?: PaginatedSearchOptions, ...linksToFollow: Array>): Observable>> { + const hrefObs = this.getVersionsEndpoint(versionHistoryId).pipe( + map((href) => searchOptions ? searchOptions.toRestUrl(href) : href) + ); + hrefObs.pipe( + take(1) + ).subscribe((href) => { + const request = new GetRequest(this.requestService.generateRequestId(), href); + this.requestService.configure(request); + }); + + return this.rdbService.buildList(hrefObs, ...linksToFollow); + } +} diff --git a/src/app/core/dspace-rest-v2/dspace-rest-v2.service.ts b/src/app/core/dspace-rest-v2/dspace-rest-v2.service.ts index 91756d412c..6eb144580c 100644 --- a/src/app/core/dspace-rest-v2/dspace-rest-v2.service.ts +++ b/src/app/core/dspace-rest-v2/dspace-rest-v2.service.ts @@ -90,6 +90,14 @@ export class DSpaceRESTv2Service { requestOptions.headers = options.headers; } + if (options && options.params) { + requestOptions.params = options.params; + } + + if (options && options.withCredentials) { + requestOptions.withCredentials = options.withCredentials; + } + if (!requestOptions.headers.has('Content-Type')) { // Because HttpHeaders is immutable, the set method returns a new object instead of updating the existing headers requestOptions.headers = requestOptions.headers.set('Content-Type', DEFAULT_CONTENT_TYPE); diff --git a/src/app/core/services/route.service.ts b/src/app/core/services/route.service.ts index 441d058c4c..59ec899576 100644 --- a/src/app/core/services/route.service.ts +++ b/src/app/core/services/route.service.ts @@ -59,7 +59,9 @@ export function parameterSelector(key: string, paramsSelector: (state: CoreState /** * Service to keep track of the current query parameters */ -@Injectable() +@Injectable({ + providedIn: 'root' +}) export class RouteService { constructor(private route: ActivatedRoute, private router: Router, private store: Store) { this.saveRouting(); diff --git a/src/app/core/shared/item.model.ts b/src/app/core/shared/item.model.ts index e7f0ae9e10..7f6cf9fe13 100644 --- a/src/app/core/shared/item.model.ts +++ b/src/app/core/shared/item.model.ts @@ -18,6 +18,8 @@ import { Relationship } from './item-relationships/relationship.model'; import { RELATIONSHIP } from './item-relationships/relationship.resource-type'; import { ITEM } from './item.resource-type'; import { ChildHALResource } from './child-hal-resource.model'; +import { Version } from './version.model'; +import { VERSION } from './version.resource-type'; /** * Class representing a DSpace Item @@ -67,6 +69,7 @@ export class Item extends DSpaceObject implements ChildHALResource { bundles: HALLink; owningCollection: HALLink; templateItemOf: HALLink; + version: HALLink; self: HALLink; }; @@ -77,6 +80,13 @@ export class Item extends DSpaceObject implements ChildHALResource { @link(COLLECTION) owningCollection?: Observable>; + /** + * The version this item represents in its history + * Will be undefined unless the version {@link HALLink} has been resolved. + */ + @link(VERSION) + version?: Observable>; + /** * The list of Bundles inside this Item * Will be undefined unless the bundles {@link HALLink} has been resolved. diff --git a/src/app/core/shared/version-history.model.ts b/src/app/core/shared/version-history.model.ts new file mode 100644 index 0000000000..a8ce982fb2 --- /dev/null +++ b/src/app/core/shared/version-history.model.ts @@ -0,0 +1,39 @@ +import { deserialize, autoserialize, inheritSerialization } from 'cerialize'; +import { excludeFromEquals } from '../utilities/equals.decorators'; +import { Observable } from 'rxjs/internal/Observable'; +import { RemoteData } from '../data/remote-data'; +import { PaginatedList } from '../data/paginated-list'; +import { Version } from './version.model'; +import { VERSION_HISTORY } from './version-history.resource-type'; +import { link, typedObject } from '../cache/builders/build-decorators'; +import { DSpaceObject } from './dspace-object.model'; +import { HALLink } from './hal-link.model'; +import { VERSION } from './version.resource-type'; + +/** + * Class representing a DSpace Version History + */ +@typedObject +@inheritSerialization(DSpaceObject) +export class VersionHistory extends DSpaceObject { + static type = VERSION_HISTORY; + + @deserialize + _links: { + self: HALLink; + versions: HALLink; + }; + + /** + * The identifier of this Version History + */ + @autoserialize + id: string; + + /** + * The list of versions within this history + */ + @excludeFromEquals + @link(VERSION, true) + versions: Observable>>; +} diff --git a/src/app/core/shared/version-history.resource-type.ts b/src/app/core/shared/version-history.resource-type.ts new file mode 100644 index 0000000000..c6d92ce138 --- /dev/null +++ b/src/app/core/shared/version-history.resource-type.ts @@ -0,0 +1,9 @@ +import { ResourceType } from './resource-type'; + +/** + * The resource type for VersionHistory + * + * Needs to be in a separate file to prevent circular + * dependencies in webpack. + */ +export const VERSION_HISTORY = new ResourceType('versionhistory'); diff --git a/src/app/core/shared/version.model.ts b/src/app/core/shared/version.model.ts new file mode 100644 index 0000000000..6e109ba9c2 --- /dev/null +++ b/src/app/core/shared/version.model.ts @@ -0,0 +1,76 @@ +import { deserialize, autoserialize, inheritSerialization } from 'cerialize'; +import { excludeFromEquals } from '../utilities/equals.decorators'; +import { Item } from './item.model'; +import { RemoteData } from '../data/remote-data'; +import { Observable } from 'rxjs/internal/Observable'; +import { VersionHistory } from './version-history.model'; +import { EPerson } from '../eperson/models/eperson.model'; +import { VERSION } from './version.resource-type'; +import { HALLink } from './hal-link.model'; +import { link, typedObject } from '../cache/builders/build-decorators'; +import { VERSION_HISTORY } from './version-history.resource-type'; +import { ITEM } from './item.resource-type'; +import { EPERSON } from '../eperson/models/eperson.resource-type'; +import { DSpaceObject } from './dspace-object.model'; + +/** + * Class representing a DSpace Version + */ +@typedObject +@inheritSerialization(DSpaceObject) +export class Version extends DSpaceObject { + static type = VERSION; + + @deserialize + _links: { + self: HALLink; + item: HALLink; + versionhistory: HALLink; + eperson: HALLink; + }; + + /** + * The identifier of this Version + */ + @autoserialize + id: string; + + /** + * The version number of the version's history this version represents + */ + @autoserialize + version: number; + + /** + * The summary for the changes made in this version + */ + @autoserialize + summary: string; + + /** + * The Date this version was created + */ + @deserialize + created: Date; + + /** + * The full version history this version is apart of + */ + @excludeFromEquals + @link(VERSION_HISTORY) + versionhistory: Observable>; + + /** + * The item this version represents + */ + @excludeFromEquals + @link(ITEM) + item: Observable>; + + /** + * The e-person who created this version + */ + @excludeFromEquals + @link(EPERSON) + eperson: Observable>; +} diff --git a/src/app/core/shared/version.resource-type.ts b/src/app/core/shared/version.resource-type.ts new file mode 100644 index 0000000000..ac0f56239e --- /dev/null +++ b/src/app/core/shared/version.resource-type.ts @@ -0,0 +1,9 @@ +import { ResourceType } from './resource-type'; + +/** + * The resource type for Version + * + * Needs to be in a separate file to prevent circular + * dependencies in webpack. + */ +export const VERSION = new ResourceType('version'); diff --git a/src/app/shared/auth-nav-menu/auth-nav-menu.component.html b/src/app/shared/auth-nav-menu/auth-nav-menu.component.html index 86de30c23e..a05381fee8 100644 --- a/src/app/shared/auth-nav-menu/auth-nav-menu.component.html +++ b/src/app/shared/auth-nav-menu/auth-nav-menu.component.html @@ -1,26 +1,33 @@ diff --git a/src/app/shared/item/item-versions/item-versions.component.html b/src/app/shared/item/item-versions/item-versions.component.html new file mode 100644 index 0000000000..6e93f4c7ca --- /dev/null +++ b/src/app/shared/item/item-versions/item-versions.component.html @@ -0,0 +1,47 @@ +
+
+
+

{{"item.version.history.head" | translate}}

+ + + + + + + + + + + + + + + + + + + + +
{{"item.version.history.table.version" | translate}}{{"item.version.history.table.item" | translate}}{{"item.version.history.table.editor" | translate}}{{"item.version.history.table.date" | translate}}{{"item.version.history.table.summary" | translate}}
{{version?.version}} + + {{item?.handle}} + * + + + + {{eperson?.name}} + + {{version?.created}}{{version?.summary}}
+
* {{"item.version.history.selected" | translate}}
+
+ +
+
+
diff --git a/src/app/shared/item/item-versions/item-versions.component.spec.ts b/src/app/shared/item/item-versions/item-versions.component.spec.ts new file mode 100644 index 0000000000..18fa4cf983 --- /dev/null +++ b/src/app/shared/item/item-versions/item-versions.component.spec.ts @@ -0,0 +1,121 @@ +import { ItemVersionsComponent } from './item-versions.component'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { VarDirective } from '../../utils/var.directive'; +import { TranslateModule } from '@ngx-translate/core'; +import { RouterTestingModule } from '@angular/router/testing'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { Item } from '../../../core/shared/item.model'; +import { Version } from '../../../core/shared/version.model'; +import { VersionHistory } from '../../../core/shared/version-history.model'; +import { createPaginatedList, createSuccessfulRemoteDataObject$ } from '../../testing/utils'; +import { VersionHistoryDataService } from '../../../core/data/version-history-data.service'; +import { By } from '@angular/platform-browser'; + +describe('ItemVersionsComponent', () => { + let component: ItemVersionsComponent; + let fixture: ComponentFixture; + + const versionHistory = Object.assign(new VersionHistory(), { + id: '1' + }); + const version1 = Object.assign(new Version(), { + id: '1', + version: 1, + created: new Date(2020, 1, 1), + summary: 'first version', + versionhistory: createSuccessfulRemoteDataObject$(versionHistory) + }); + const version2 = Object.assign(new Version(), { + id: '2', + version: 2, + summary: 'second version', + created: new Date(2020, 1, 2), + versionhistory: createSuccessfulRemoteDataObject$(versionHistory) + }); + const versions = [version1, version2]; + versionHistory.versions = createSuccessfulRemoteDataObject$(createPaginatedList(versions)); + const item1 = Object.assign(new Item(), { + uuid: 'item-identifier-1', + handle: '123456789/1', + version: createSuccessfulRemoteDataObject$(version1) + }); + const item2 = Object.assign(new Item(), { + uuid: 'item-identifier-2', + handle: '123456789/2', + version: createSuccessfulRemoteDataObject$(version2) + }); + const items = [item1, item2]; + version1.item = createSuccessfulRemoteDataObject$(item1); + version2.item = createSuccessfulRemoteDataObject$(item2); + const versionHistoryService = jasmine.createSpyObj('versionHistoryService', { + getVersions: createSuccessfulRemoteDataObject$(createPaginatedList(versions)) + }); + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ItemVersionsComponent, VarDirective], + imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([])], + providers: [ + { provide: VersionHistoryDataService, useValue: versionHistoryService } + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(ItemVersionsComponent); + component = fixture.componentInstance; + component.item = item1; + fixture.detectChanges(); + }); + + it(`should display ${versions.length} rows`, () => { + const rows = fixture.debugElement.queryAll(By.css('tbody tr')); + expect(rows.length).toBe(versions.length); + }); + + versions.forEach((version: Version, index: number) => { + const versionItem = items[index]; + + it(`should display version ${version.version} in the correct column for version ${version.id}`, () => { + const id = fixture.debugElement.query(By.css(`#version-row-${version.id} .version-row-element-version`)); + expect(id.nativeElement.textContent).toEqual('' + version.version); + }); + + it(`should display item handle ${versionItem.handle} in the correct column for version ${version.id}`, () => { + const item = fixture.debugElement.query(By.css(`#version-row-${version.id} .version-row-element-item`)); + expect(item.nativeElement.textContent).toContain(versionItem.handle); + }); + + // This version's item is equal to the component's item (the selected item) + // Check if the handle contains an asterisk + if (item1.uuid === versionItem.uuid) { + it('should add an asterisk to the handle of the selected item', () => { + const item = fixture.debugElement.query(By.css(`#version-row-${version.id} .version-row-element-item`)); + expect(item.nativeElement.textContent).toContain('*'); + }); + } + + it(`should display date ${version.created} in the correct column for version ${version.id}`, () => { + const date = fixture.debugElement.query(By.css(`#version-row-${version.id} .version-row-element-date`)); + expect(date.nativeElement.textContent).toEqual('' + version.created); + }); + + it(`should display summary ${version.summary} in the correct column for version ${version.id}`, () => { + const summary = fixture.debugElement.query(By.css(`#version-row-${version.id} .version-row-element-summary`)); + expect(summary.nativeElement.textContent).toEqual(version.summary); + }); + }); + + describe('switchPage', () => { + const page = 5; + + beforeEach(() => { + component.switchPage(page); + }); + + it('should set the option\'s currentPage to the new page', () => { + expect(component.options.currentPage).toEqual(page); + }); + }); +}); diff --git a/src/app/shared/item/item-versions/item-versions.component.ts b/src/app/shared/item/item-versions/item-versions.component.ts new file mode 100644 index 0000000000..684599d3b5 --- /dev/null +++ b/src/app/shared/item/item-versions/item-versions.component.ts @@ -0,0 +1,130 @@ +import { Component, Input, OnInit } from '@angular/core'; +import { Item } from '../../../core/shared/item.model'; +import { Version } from '../../../core/shared/version.model'; +import { RemoteData } from '../../../core/data/remote-data'; +import { Observable } from 'rxjs/internal/Observable'; +import { VersionHistory } from '../../../core/shared/version-history.model'; +import { getAllSucceededRemoteData, getRemoteDataPayload } from '../../../core/shared/operators'; +import { map, startWith, switchMap } from 'rxjs/operators'; +import { combineLatest as observableCombineLatest } from 'rxjs'; +import { PaginatedList } from '../../../core/data/paginated-list'; +import { PaginationComponentOptions } from '../../pagination/pagination-component-options.model'; +import { BehaviorSubject } from 'rxjs/internal/BehaviorSubject'; +import { VersionHistoryDataService } from '../../../core/data/version-history-data.service'; +import { PaginatedSearchOptions } from '../../search/paginated-search-options.model'; +import { AlertType } from '../../alert/aletr-type'; +import { followLink } from '../../utils/follow-link-config.model'; + +@Component({ + selector: 'ds-item-versions', + templateUrl: './item-versions.component.html' +}) +/** + * Component listing all available versions of the history the provided item is a part of + */ +export class ItemVersionsComponent implements OnInit { + /** + * The item to display a version history for + */ + @Input() item: Item; + + /** + * An option to display the list of versions, even when there aren't any. + * Instead of the table, an alert will be displayed, notifying the user there are no other versions present + * for the current item. + */ + @Input() displayWhenEmpty = false; + + /** + * Whether or not to display the title + */ + @Input() displayTitle = true; + + /** + * The AlertType enumeration + * @type {AlertType} + */ + AlertTypeEnum = AlertType; + + /** + * The item's version + */ + versionRD$: Observable>; + + /** + * The item's full version history + */ + versionHistoryRD$: Observable>; + + /** + * The version history's list of versions + */ + versionsRD$: Observable>>; + + /** + * Verify if the list of versions has at least one e-person to display + * Used to hide the "Editor" column when no e-persons are present to display + */ + hasEpersons$: Observable; + + /** + * The amount of versions to display per page + */ + pageSize = 10; + + /** + * The page options to use for fetching the versions + * Start at page 1 and always use the set page size + */ + options = Object.assign(new PaginationComponentOptions(),{ + id: 'item-versions-options', + currentPage: 1, + pageSize: this.pageSize + }); + + /** + * The current page being displayed + */ + currentPage$ = new BehaviorSubject(1); + + constructor(private versionHistoryService: VersionHistoryDataService) { + } + + /** + * Initialize all observables + */ + ngOnInit(): void { + this.versionRD$ = this.item.version; + this.versionHistoryRD$ = this.versionRD$.pipe( + getAllSucceededRemoteData(), + getRemoteDataPayload(), + switchMap((version: Version) => version.versionhistory) + ); + const versionHistory$ = this.versionHistoryRD$.pipe( + getAllSucceededRemoteData(), + getRemoteDataPayload(), + ); + this.versionsRD$ = observableCombineLatest(versionHistory$, this.currentPage$).pipe( + switchMap(([versionHistory, page]: [VersionHistory, number]) => + this.versionHistoryService.getVersions(versionHistory.id, + new PaginatedSearchOptions({pagination: Object.assign({}, this.options, { currentPage: page })}), + followLink('item'), followLink('eperson'))) + ); + this.hasEpersons$ = this.versionsRD$.pipe( + getAllSucceededRemoteData(), + getRemoteDataPayload(), + map((versions: PaginatedList) => versions.page.filter((version: Version) => version.eperson !== undefined).length > 0), + startWith(false) + ); + } + + /** + * Update the current page + * @param page + */ + switchPage(page: number) { + this.options.currentPage = page; + this.currentPage$.next(page); + } + +} diff --git a/src/app/shared/item/item-versions/notice/item-versions-notice.component.html b/src/app/shared/item/item-versions/notice/item-versions-notice.component.html new file mode 100644 index 0000000000..cec0bdcb04 --- /dev/null +++ b/src/app/shared/item/item-versions/notice/item-versions-notice.component.html @@ -0,0 +1,5 @@ + + diff --git a/src/app/shared/item/item-versions/notice/item-versions-notice.component.spec.ts b/src/app/shared/item/item-versions/notice/item-versions-notice.component.spec.ts new file mode 100644 index 0000000000..ffcd1d897e --- /dev/null +++ b/src/app/shared/item/item-versions/notice/item-versions-notice.component.spec.ts @@ -0,0 +1,93 @@ +import { ItemVersionsNoticeComponent } from './item-versions-notice.component'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { RouterTestingModule } from '@angular/router/testing'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { VersionHistory } from '../../../../core/shared/version-history.model'; +import { Version } from '../../../../core/shared/version.model'; +import { createPaginatedList, createSuccessfulRemoteDataObject$ } from '../../../testing/utils'; +import { Item } from '../../../../core/shared/item.model'; +import { VersionHistoryDataService } from '../../../../core/data/version-history-data.service'; +import { By } from '@angular/platform-browser'; + +describe('ItemVersionsNoticeComponent', () => { + let component: ItemVersionsNoticeComponent; + let fixture: ComponentFixture; + + const versionHistory = Object.assign(new VersionHistory(), { + id: '1' + }); + const firstVersion = Object.assign(new Version(), { + id: '1', + version: 1, + created: new Date(2020, 1, 1), + summary: 'first version', + versionhistory: createSuccessfulRemoteDataObject$(versionHistory) + }); + const latestVersion = Object.assign(new Version(), { + id: '2', + version: 2, + summary: 'latest version', + created: new Date(2020, 1, 2), + versionhistory: createSuccessfulRemoteDataObject$(versionHistory) + }); + const versions = [latestVersion, firstVersion]; + versionHistory.versions = createSuccessfulRemoteDataObject$(createPaginatedList(versions)); + const firstItem = Object.assign(new Item(), { + id: 'first_item_id', + uuid: 'first_item_id', + handle: '123456789/1', + version: createSuccessfulRemoteDataObject$(firstVersion) + }); + const latestItem = Object.assign(new Item(), { + id: 'latest_item_id', + uuid: 'latest_item_id', + handle: '123456789/2', + version: createSuccessfulRemoteDataObject$(latestVersion) + }); + firstVersion.item = createSuccessfulRemoteDataObject$(firstItem); + latestVersion.item = createSuccessfulRemoteDataObject$(latestItem); + const versionHistoryService = jasmine.createSpyObj('versionHistoryService', { + getVersions: createSuccessfulRemoteDataObject$(createPaginatedList(versions)) + }); + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ItemVersionsNoticeComponent], + imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([])], + providers: [ + { provide: VersionHistoryDataService, useValue: versionHistoryService } + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + })); + + describe('when the item is the latest version', () => { + beforeEach(() => { + initComponentWithItem(latestItem); + }); + + it('should not display a notice', () => { + const alert = fixture.debugElement.query(By.css('ds-alert')); + expect(alert).toBeNull(); + }); + }); + + describe('when the item is not the latest version', () => { + beforeEach(() => { + initComponentWithItem(firstItem); + }); + + it('should display a notice', () => { + const alert = fixture.debugElement.query(By.css('ds-alert')); + expect(alert).not.toBeNull(); + }); + }); + + function initComponentWithItem(item: Item) { + fixture = TestBed.createComponent(ItemVersionsNoticeComponent); + component = fixture.componentInstance; + component.item = item; + fixture.detectChanges(); + } +}); diff --git a/src/app/shared/item/item-versions/notice/item-versions-notice.component.ts b/src/app/shared/item/item-versions/notice/item-versions-notice.component.ts new file mode 100644 index 0000000000..c2bd316137 --- /dev/null +++ b/src/app/shared/item/item-versions/notice/item-versions-notice.component.ts @@ -0,0 +1,114 @@ +import { Component, Input, OnInit } from '@angular/core'; +import { Item } from '../../../../core/shared/item.model'; +import { PaginationComponentOptions } from '../../../pagination/pagination-component-options.model'; +import { PaginatedSearchOptions } from '../../../search/paginated-search-options.model'; +import { Observable } from 'rxjs/internal/Observable'; +import { RemoteData } from '../../../../core/data/remote-data'; +import { VersionHistory } from '../../../../core/shared/version-history.model'; +import { Version } from '../../../../core/shared/version.model'; +import { hasValue } from '../../../empty.util'; +import { getAllSucceededRemoteData, getRemoteDataPayload } from '../../../../core/shared/operators'; +import { filter, map, startWith, switchMap } from 'rxjs/operators'; +import { followLink } from '../../../utils/follow-link-config.model'; +import { VersionHistoryDataService } from '../../../../core/data/version-history-data.service'; +import { AlertType } from '../../../alert/aletr-type'; +import { combineLatest as observableCombineLatest } from 'rxjs'; +import { getItemPageRoute } from '../../../../+item-page/item-page-routing.module'; + +@Component({ + selector: 'ds-item-versions-notice', + templateUrl: './item-versions-notice.component.html' +}) +/** + * Component for displaying a warning notice when the item is not the latest version within its version history + * The notice contains a link to the latest version's item page + */ +export class ItemVersionsNoticeComponent implements OnInit { + /** + * The item to display a version notice for + */ + @Input() item: Item; + + /** + * The item's version + */ + versionRD$: Observable>; + + /** + * The item's full version history + */ + versionHistoryRD$: Observable>; + + /** + * The latest version of the item's version history + */ + latestVersion$: Observable; + + /** + * Is the item's version equal to the latest version from the version history? + * This will determine whether or not to display a notice linking to the latest version + */ + isLatestVersion$: Observable; + + /** + * Pagination options to fetch a single version on the first page (this is the latest version in the history) + */ + latestVersionOptions = Object.assign(new PaginationComponentOptions(),{ + id: 'item-newest-version-options', + currentPage: 1, + pageSize: 1 + }); + + /** + * The AlertType enumeration + * @type {AlertType} + */ + public AlertTypeEnum = AlertType; + + constructor(private versionHistoryService: VersionHistoryDataService) { + } + + /** + * Initialize the component's observables + */ + ngOnInit(): void { + const latestVersionSearch = new PaginatedSearchOptions({pagination: this.latestVersionOptions}); + if (hasValue(this.item.version)) { + this.versionRD$ = this.item.version; + this.versionHistoryRD$ = this.versionRD$.pipe( + getAllSucceededRemoteData(), + getRemoteDataPayload(), + switchMap((version: Version) => version.versionhistory) + ); + const versionHistory$ = this.versionHistoryRD$.pipe( + getAllSucceededRemoteData(), + getRemoteDataPayload(), + ); + this.latestVersion$ = versionHistory$.pipe( + switchMap((versionHistory: VersionHistory) => + this.versionHistoryService.getVersions(versionHistory.id, latestVersionSearch, followLink('item'))), + getAllSucceededRemoteData(), + getRemoteDataPayload(), + filter((versions) => versions.page.length > 0), + map((versions) => versions.page[0]) + ); + + this.isLatestVersion$ = observableCombineLatest( + this.versionRD$.pipe(getAllSucceededRemoteData(), getRemoteDataPayload()), this.latestVersion$ + ).pipe( + map(([itemVersion, latestVersion]: [Version, Version]) => itemVersion.id === latestVersion.id), + startWith(true) + ) + } + } + + /** + * Get the item page url + * @param item The item for which the url is requested + */ + getItemPage(item: Item): string { + if (hasValue(item)) { + return getItemPageRoute(item.id); + } + } +} diff --git a/src/app/shared/log-in/container/log-in-container.component.html b/src/app/shared/log-in/container/log-in-container.component.html new file mode 100644 index 0000000000..bef6f43b66 --- /dev/null +++ b/src/app/shared/log-in/container/log-in-container.component.html @@ -0,0 +1,5 @@ + + + diff --git a/src/app/shared/log-in/container/log-in-container.component.scss b/src/app/shared/log-in/container/log-in-container.component.scss new file mode 100644 index 0000000000..0255b71dac --- /dev/null +++ b/src/app/shared/log-in/container/log-in-container.component.scss @@ -0,0 +1,21 @@ +:host ::ng-deep .card { + margin-bottom: $submission-sections-margin-bottom; + overflow: unset; +} + +.section-focus { + border-radius: $border-radius; + box-shadow: $btn-focus-box-shadow; +} + +// TODO to remove the following when upgrading @ng-bootstrap +:host ::ng-deep .card:first-of-type { + border-bottom: $card-border-width solid $card-border-color !important; + border-bottom-left-radius: $card-border-radius !important; + border-bottom-right-radius: $card-border-radius !important; +} + +:host ::ng-deep .card-header button { + box-shadow: none !important; + width: 100%; +} diff --git a/src/app/shared/log-in/container/log-in-container.component.spec.ts b/src/app/shared/log-in/container/log-in-container.component.spec.ts new file mode 100644 index 0000000000..c819b0cc8d --- /dev/null +++ b/src/app/shared/log-in/container/log-in-container.component.spec.ts @@ -0,0 +1,108 @@ +import { Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; +import { async, ComponentFixture, inject, TestBed } from '@angular/core/testing'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; + +import { StoreModule } from '@ngrx/store'; +import { TranslateModule } from '@ngx-translate/core'; + +import { LogInContainerComponent } from './log-in-container.component'; +import { authReducer } from '../../../core/auth/auth.reducer'; +import { SharedModule } from '../../shared.module'; +import { createTestComponent } from '../../testing/utils'; +import { AuthService } from '../../../core/auth/auth.service'; +import { AuthMethod } from '../../../core/auth/models/auth.method'; +import { AuthServiceStub } from '../../testing/auth-service-stub'; + +describe('LogInContainerComponent', () => { + + let component: LogInContainerComponent; + let fixture: ComponentFixture; + + const authMethod = new AuthMethod('password'); + + beforeEach(async(() => { + // refine the test module by declaring the test component + TestBed.configureTestingModule({ + imports: [ + FormsModule, + ReactiveFormsModule, + StoreModule.forRoot(authReducer), + SharedModule, + TranslateModule.forRoot() + ], + declarations: [ + TestComponent + ], + providers: [ + {provide: AuthService, useClass: AuthServiceStub}, + LogInContainerComponent + ], + schemas: [ + CUSTOM_ELEMENTS_SCHEMA + ] + }) + .compileComponents(); + + })); + + describe('', () => { + let testComp: TestComponent; + let testFixture: ComponentFixture; + + // synchronous beforeEach + beforeEach(() => { + const html = ` `; + + testFixture = createTestComponent(html, TestComponent) as ComponentFixture; + testComp = testFixture.componentInstance; + }); + + afterEach(() => { + testFixture.destroy(); + }); + + it('should create LogInContainerComponent', inject([LogInContainerComponent], (app: LogInContainerComponent) => { + + expect(app).toBeDefined(); + + })); + }); + + describe('', () => { + beforeEach(() => { + fixture = TestBed.createComponent(LogInContainerComponent); + component = fixture.componentInstance; + + spyOn(component, 'getAuthMethodContent').and.callThrough(); + component.authMethod = authMethod; + fixture.detectChanges(); + }); + + afterEach(() => { + fixture.destroy(); + component = null; + }); + + it('should inject component properly', () => { + + component.ngOnInit(); + fixture.detectChanges(); + + expect(component.getAuthMethodContent).toHaveBeenCalled(); + + }); + + }); + +}); + +// declare a test component +@Component({ + selector: 'ds-test-cmp', + template: `` +}) +class TestComponent { + + isStandalonePage = true; + +} diff --git a/src/app/shared/log-in/container/log-in-container.component.ts b/src/app/shared/log-in/container/log-in-container.component.ts new file mode 100644 index 0000000000..660e616b9d --- /dev/null +++ b/src/app/shared/log-in/container/log-in-container.component.ts @@ -0,0 +1,51 @@ +import { Component, Injector, Input, OnInit } from '@angular/core'; + +import { rendersAuthMethodType } from '../methods/log-in.methods-decorator'; +import { AuthMethod } from '../../../core/auth/models/auth.method'; + +/** + * This component represents a component container for log-in methods available. + */ +@Component({ + selector: 'ds-log-in-container', + templateUrl: './log-in-container.component.html', + styleUrls: ['./log-in-container.component.scss'] +}) +export class LogInContainerComponent implements OnInit { + + @Input() authMethod: AuthMethod; + + /** + * Injector to inject a section component with the @Input parameters + * @type {Injector} + */ + public objectInjector: Injector; + + /** + * Initialize instance variables + * + * @param {Injector} injector + */ + constructor(private injector: Injector) { + } + + /** + * Initialize all instance variables + */ + ngOnInit() { + this.objectInjector = Injector.create({ + providers: [ + { provide: 'authMethodProvider', useFactory: () => (this.authMethod), deps: [] }, + ], + parent: this.injector + }); + } + + /** + * Find the correct component based on the AuthMethod's type + */ + getAuthMethodContent(): string { + return rendersAuthMethodType(this.authMethod.authMethodType) + } + +} diff --git a/src/app/shared/log-in/log-in.component.html b/src/app/shared/log-in/log-in.component.html index fe9a506e71..8e23f00d9b 100644 --- a/src/app/shared/log-in/log-in.component.html +++ b/src/app/shared/log-in/log-in.component.html @@ -1,28 +1,13 @@ - diff --git a/src/app/shared/log-in/log-in.component.scss b/src/app/shared/log-in/log-in.component.scss index 0eda382c0a..caaeef3dc7 100644 --- a/src/app/shared/log-in/log-in.component.scss +++ b/src/app/shared/log-in/log-in.component.scss @@ -1,13 +1,3 @@ -.form-login .form-control:focus { - z-index: 2; +.login-container { + max-width: 350px; } -.form-login input[type="email"] { - margin-bottom: -1px; - border-bottom-right-radius: 0; - border-bottom-left-radius: 0; -} -.form-login input[type="password"] { - border-top-left-radius: 0; - border-top-right-radius: 0; -} - diff --git a/src/app/shared/log-in/log-in.component.spec.ts b/src/app/shared/log-in/log-in.component.spec.ts index 0479b92721..fdf1bec574 100644 --- a/src/app/shared/log-in/log-in.component.spec.ts +++ b/src/app/shared/log-in/log-in.component.spec.ts @@ -1,7 +1,5 @@ -import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; +import { Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; import { async, ComponentFixture, inject, TestBed } from '@angular/core/testing'; -import { FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'; - import { By } from '@angular/platform-browser'; import { Store, StoreModule } from '@ngrx/store'; @@ -10,27 +8,27 @@ import { authReducer } from '../../core/auth/auth.reducer'; import { EPersonMock } from '../testing/eperson.mock'; import { EPerson } from '../../core/eperson/models/eperson.model'; import { TranslateModule } from '@ngx-translate/core'; + +import { LogInComponent } from './log-in.component'; import { AuthService } from '../../core/auth/auth.service'; -import { AuthServiceStub } from '../testing/auth-service.stub'; +import { authMethodsMock, AuthServiceStub } from '../testing/auth-service.stub'; import { AppState, storeModuleConfig } from '../../app.reducer'; describe('LogInComponent', () => { let component: LogInComponent; let fixture: ComponentFixture; - let page: Page; - let user: EPerson; - - const authState = { - authenticated: false, - loaded: false, - loading: false, + const initialState = { + core: { + auth: { + authenticated: false, + loaded: false, + loading: false, + authMethods: authMethodsMock + } + } }; - beforeEach(() => { - user = EPersonMock; - }); - beforeEach(async(() => { // refine the test module by declaring the test component TestBed.configureTestingModule({ @@ -43,13 +41,19 @@ describe('LogInComponent', () => { strictActionImmutability: false } }), + SharedModule, TranslateModule.forRoot() ], declarations: [ - LogInComponent + TestComponent ], providers: [ - {provide: AuthService, useClass: AuthServiceStub} + { provide: AuthService, useClass: AuthServiceStub }, + { provide: NativeWindowService, useFactory: NativeWindowMockFactory }, + { provide: Router, useValue: new RouterStub() }, + { provide: ActivatedRoute, useValue: new ActivatedRouteStub() }, + provideMockStore({ initialState }), + LogInComponent ], schemas: [ CUSTOM_ELEMENTS_SCHEMA @@ -59,75 +63,58 @@ describe('LogInComponent', () => { })); - beforeEach(inject([Store], (store: Store) => { - store - .subscribe((state) => { - (state as any).core = Object.create({}); - (state as any).core.auth = authState; - }); + describe('', () => { + let testComp: TestComponent; + let testFixture: ComponentFixture; - // create component and test fixture - fixture = TestBed.createComponent(LogInComponent); + // synchronous beforeEach + beforeEach(() => { + const html = ` `; - // get test component from the fixture - component = fixture.componentInstance; - - // create page - page = new Page(component, fixture); - - // verify the fixture is stable (no pending tasks) - fixture.whenStable().then(() => { - page.addPageElements(); + testFixture = createTestComponent(html, TestComponent) as ComponentFixture; + testComp = testFixture.componentInstance; }); - })); + afterEach(() => { + testFixture.destroy(); + }); - it('should create a FormGroup comprised of FormControls', () => { - fixture.detectChanges(); - expect(component.form instanceof FormGroup).toBe(true); + it('should create LogInComponent', inject([LogInComponent], (app: LogInComponent) => { + + expect(app).toBeDefined(); + + })); }); - it('should authenticate', () => { - fixture.detectChanges(); + describe('', () => { + beforeEach(() => { + fixture = TestBed.createComponent(LogInComponent); + component = fixture.componentInstance; - // set FormControl values - component.form.controls.email.setValue('user'); - component.form.controls.password.setValue('password'); + fixture.detectChanges(); + }); - // submit form - component.submit(); + afterEach(() => { + fixture.destroy(); + component = null; + }); - // verify Store.dispatch() is invoked - expect(page.navigateSpy.calls.any()).toBe(true, 'Store.dispatch not invoked'); + it('should render a log-in container component for each auth method available', () => { + const loginContainers = fixture.debugElement.queryAll(By.css('ds-log-in-container')); + expect(loginContainers.length).toBe(2); + + }); }); }); -/** - * I represent the DOM elements and attach spies. - * - * @class Page - */ -class Page { +// declare a test component +@Component({ + selector: 'ds-test-cmp', + template: `` +}) +class TestComponent { - public emailInput: HTMLInputElement; - public navigateSpy: jasmine.Spy; - public passwordInput: HTMLInputElement; + isStandalonePage = true; - constructor(private component: LogInComponent, private fixture: ComponentFixture) { - // use injector to get services - const injector = fixture.debugElement.injector; - const store = injector.get(Store); - - // add spies - this.navigateSpy = spyOn(store, 'dispatch'); - } - - public addPageElements() { - const emailInputSelector = 'input[formcontrolname=\'email\']'; - this.emailInput = this.fixture.debugElement.query(By.css(emailInputSelector)).nativeElement; - - const passwordInputSelector = 'input[formcontrolname=\'password\']'; - this.passwordInput = this.fixture.debugElement.query(By.css(passwordInputSelector)).nativeElement; - } } diff --git a/src/app/shared/log-in/log-in.component.ts b/src/app/shared/log-in/log-in.component.ts index b6b97230dd..92350de442 100644 --- a/src/app/shared/log-in/log-in.component.ts +++ b/src/app/shared/log-in/log-in.component.ts @@ -1,26 +1,13 @@ -import { filter, map, takeWhile } from 'rxjs/operators'; import { Component, Input, OnDestroy, OnInit } from '@angular/core'; -import { FormBuilder, FormGroup, Validators } from '@angular/forms'; -import { select, Store } from '@ngrx/store'; import { Observable } from 'rxjs'; -import { - AuthenticateAction, - ResetAuthenticationMessagesAction -} from '../../core/auth/auth.actions'; +import { filter, takeWhile, } from 'rxjs/operators'; +import { select, Store } from '@ngrx/store'; -import { - getAuthenticationError, - getAuthenticationInfo, - isAuthenticated, - isAuthenticationLoading, -} from '../../core/auth/selectors'; +import { AuthMethod } from '../../core/auth/models/auth.method'; +import { getAuthenticationMethods, isAuthenticated, isAuthenticationLoading } from '../../core/auth/selectors'; import { CoreState } from '../../core/core.reducers'; - -import { isNotEmpty } from '../empty.util'; -import { fadeOut } from '../animations/fade'; import { AuthService } from '../../core/auth/auth.service'; -import { Router } from '@angular/router'; /** * /users/sign-in @@ -29,34 +16,21 @@ import { Router } from '@angular/router'; @Component({ selector: 'ds-log-in', templateUrl: './log-in.component.html', - styleUrls: ['./log-in.component.scss'], - animations: [fadeOut] + styleUrls: ['./log-in.component.scss'] }) -export class LogInComponent implements OnDestroy, OnInit { +export class LogInComponent implements OnInit, OnDestroy { /** - * The error if authentication fails. - * @type {Observable} - */ - public error: Observable; - - /** - * Has authentication error. + * A boolean representing if LogInComponent is in a standalone page * @type {boolean} */ - public hasError = false; + @Input() isStandalonePage: boolean; /** - * The authentication info message. - * @type {Observable} + * The list of authentication methods available + * @type {AuthMethod[]} */ - public message: Observable; - - /** - * Has authentication message. - * @type {boolean} - */ - public hasMessage = false; + public authMethods: Observable; /** * Whether user is authenticated. @@ -70,69 +44,28 @@ export class LogInComponent implements OnDestroy, OnInit { */ public loading: Observable; - /** - * The authentication form. - * @type {FormGroup} - */ - public form: FormGroup; - /** * Component state. * @type {boolean} */ private alive = true; - @Input() isStandalonePage: boolean; - - /** - * @constructor - * @param {AuthService} authService - * @param {FormBuilder} formBuilder - * @param {Router} router - * @param {Store} store - */ - constructor( - private authService: AuthService, - private formBuilder: FormBuilder, - private store: Store - ) { + constructor(private store: Store, + private authService: AuthService,) { } - /** - * Lifecycle hook that is called after data-bound properties of a directive are initialized. - * @method ngOnInit - */ - public ngOnInit() { - // set isAuthenticated - this.isAuthenticated = this.store.pipe(select(isAuthenticated)); + ngOnInit(): void { - // set formGroup - this.form = this.formBuilder.group({ - email: ['', Validators.required], - password: ['', Validators.required] - }); - - // set error - this.error = this.store.pipe(select( - getAuthenticationError), - map((error) => { - this.hasError = (isNotEmpty(error)); - return error; - }) - ); - - // set error - this.message = this.store.pipe( - select(getAuthenticationInfo), - map((message) => { - this.hasMessage = (isNotEmpty(message)); - return message; - }) + this.authMethods = this.store.pipe( + select(getAuthenticationMethods), ); // set loading this.loading = this.store.pipe(select(isAuthenticationLoading)); + // set isAuthenticated + this.isAuthenticated = this.store.pipe(select(isAuthenticated)); + // subscribe to success this.store.pipe( select(isAuthenticated), @@ -142,55 +75,11 @@ export class LogInComponent implements OnDestroy, OnInit { this.authService.redirectAfterLoginSuccess(this.isStandalonePage); } ); + } - /** - * Lifecycle hook that is called when a directive, pipe or service is destroyed. - * @method ngOnDestroy - */ - public ngOnDestroy() { + ngOnDestroy(): void { this.alive = false; } - /** - * Reset error or message. - */ - public resetErrorOrMessage() { - if (this.hasError || this.hasMessage) { - this.store.dispatch(new ResetAuthenticationMessagesAction()); - this.hasError = false; - this.hasMessage = false; - } - } - - /** - * To the registration page. - * @method register - */ - public register() { - // TODO enable after registration process is done - // this.router.navigate(['/register']); - } - - /** - * Submit the authentication form. - * @method submit - */ - public submit() { - this.resetErrorOrMessage(); - // get email and password values - const email: string = this.form.get('email').value; - const password: string = this.form.get('password').value; - - // trim values - email.trim(); - password.trim(); - - // dispatch AuthenticationAction - this.store.dispatch(new AuthenticateAction(email, password)); - - // clear form - this.form.reset(); - } - } diff --git a/src/app/shared/log-in/methods/log-in.methods-decorator.ts b/src/app/shared/log-in/methods/log-in.methods-decorator.ts new file mode 100644 index 0000000000..0614bdeb51 --- /dev/null +++ b/src/app/shared/log-in/methods/log-in.methods-decorator.ts @@ -0,0 +1,16 @@ +import { AuthMethodType } from '../../../core/auth/models/auth.method-type'; + +const authMethodsMap = new Map(); + +export function renderAuthMethodFor(authMethodType: AuthMethodType) { + return function decorator(objectElement: any) { + if (!objectElement) { + return; + } + authMethodsMap.set(authMethodType, objectElement); + }; +} + +export function rendersAuthMethodType(authMethodType: AuthMethodType) { + return authMethodsMap.get(authMethodType); +} diff --git a/src/app/shared/log-in/methods/password/log-in-password.component.html b/src/app/shared/log-in/methods/password/log-in-password.component.html new file mode 100644 index 0000000000..ddd5083d44 --- /dev/null +++ b/src/app/shared/log-in/methods/password/log-in-password.component.html @@ -0,0 +1,27 @@ +
+ + + + + + + + +
diff --git a/src/app/shared/log-in/methods/password/log-in-password.component.scss b/src/app/shared/log-in/methods/password/log-in-password.component.scss new file mode 100644 index 0000000000..0eda382c0a --- /dev/null +++ b/src/app/shared/log-in/methods/password/log-in-password.component.scss @@ -0,0 +1,13 @@ +.form-login .form-control:focus { + z-index: 2; +} +.form-login input[type="email"] { + margin-bottom: -1px; + border-bottom-right-radius: 0; + border-bottom-left-radius: 0; +} +.form-login input[type="password"] { + border-top-left-radius: 0; + border-top-right-radius: 0; +} + diff --git a/src/app/shared/log-in/methods/password/log-in-password.component.spec.ts b/src/app/shared/log-in/methods/password/log-in-password.component.spec.ts new file mode 100644 index 0000000000..ff65a240c8 --- /dev/null +++ b/src/app/shared/log-in/methods/password/log-in-password.component.spec.ts @@ -0,0 +1,131 @@ +import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; +import { async, ComponentFixture, inject, TestBed } from '@angular/core/testing'; +import { FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'; + +import { By } from '@angular/platform-browser'; +import { Store, StoreModule } from '@ngrx/store'; +import { TranslateModule } from '@ngx-translate/core'; + +import { LogInPasswordComponent } from './log-in-password.component'; +import { EPerson } from '../../../../core/eperson/models/eperson.model'; +import { EPersonMock } from '../../../testing/eperson-mock'; +import { authReducer } from '../../../../core/auth/auth.reducer'; +import { AuthService } from '../../../../core/auth/auth.service'; +import { AuthServiceStub } from '../../../testing/auth-service-stub'; +import { AppState } from '../../../../app.reducer'; +import { AuthMethod } from '../../../../core/auth/models/auth.method'; +import { AuthMethodType } from '../../../../core/auth/models/auth.method-type'; + +describe('LogInPasswordComponent', () => { + + let component: LogInPasswordComponent; + let fixture: ComponentFixture; + let page: Page; + let user: EPerson; + + const authState = { + authenticated: false, + loaded: false, + loading: false, + }; + + beforeEach(() => { + user = EPersonMock; + }); + + beforeEach(async(() => { + // refine the test module by declaring the test component + TestBed.configureTestingModule({ + imports: [ + FormsModule, + ReactiveFormsModule, + StoreModule.forRoot(authReducer), + TranslateModule.forRoot() + ], + declarations: [ + LogInPasswordComponent + ], + providers: [ + { provide: AuthService, useClass: AuthServiceStub }, + { provide: 'authMethodProvider', useValue: new AuthMethod(AuthMethodType.Password) } + ], + schemas: [ + CUSTOM_ELEMENTS_SCHEMA + ] + }) + .compileComponents(); + + })); + + beforeEach(inject([Store], (store: Store) => { + store + .subscribe((state) => { + (state as any).core = Object.create({}); + (state as any).core.auth = authState; + }); + + // create component and test fixture + fixture = TestBed.createComponent(LogInPasswordComponent); + + // get test component from the fixture + component = fixture.componentInstance; + + // create page + page = new Page(component, fixture); + + // verify the fixture is stable (no pending tasks) + fixture.whenStable().then(() => { + page.addPageElements(); + }); + + })); + + it('should create a FormGroup comprised of FormControls', () => { + fixture.detectChanges(); + expect(component.form instanceof FormGroup).toBe(true); + }); + + it('should authenticate', () => { + fixture.detectChanges(); + + // set FormControl values + component.form.controls.email.setValue('user'); + component.form.controls.password.setValue('password'); + + // submit form + component.submit(); + + // verify Store.dispatch() is invoked + expect(page.navigateSpy.calls.any()).toBe(true, 'Store.dispatch not invoked'); + }); + +}); + +/** + * I represent the DOM elements and attach spies. + * + * @class Page + */ +class Page { + + public emailInput: HTMLInputElement; + public navigateSpy: jasmine.Spy; + public passwordInput: HTMLInputElement; + + constructor(private component: LogInPasswordComponent, private fixture: ComponentFixture) { + // use injector to get services + const injector = fixture.debugElement.injector; + const store = injector.get(Store); + + // add spies + this.navigateSpy = spyOn(store, 'dispatch'); + } + + public addPageElements() { + const emailInputSelector = 'input[formcontrolname=\'email\']'; + this.emailInput = this.fixture.debugElement.query(By.css(emailInputSelector)).nativeElement; + + const passwordInputSelector = 'input[formcontrolname=\'password\']'; + this.passwordInput = this.fixture.debugElement.query(By.css(passwordInputSelector)).nativeElement; + } +} diff --git a/src/app/shared/log-in/methods/password/log-in-password.component.ts b/src/app/shared/log-in/methods/password/log-in-password.component.ts new file mode 100644 index 0000000000..8b0dd8cc04 --- /dev/null +++ b/src/app/shared/log-in/methods/password/log-in-password.component.ts @@ -0,0 +1,144 @@ +import { map } from 'rxjs/operators'; +import { Component, Inject, OnInit } from '@angular/core'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; + +import { select, Store } from '@ngrx/store'; +import { Observable } from 'rxjs'; +import { AuthenticateAction, ResetAuthenticationMessagesAction } from '../../../../core/auth/auth.actions'; + +import { getAuthenticationError, getAuthenticationInfo, } from '../../../../core/auth/selectors'; +import { CoreState } from '../../../../core/core.reducers'; +import { isNotEmpty } from '../../../empty.util'; +import { fadeOut } from '../../../animations/fade'; +import { AuthMethodType } from '../../../../core/auth/models/auth.method-type'; +import { renderAuthMethodFor } from '../log-in.methods-decorator'; +import { AuthMethod } from '../../../../core/auth/models/auth.method'; + +/** + * /users/sign-in + * @class LogInPasswordComponent + */ +@Component({ + selector: 'ds-log-in-password', + templateUrl: './log-in-password.component.html', + styleUrls: ['./log-in-password.component.scss'], + animations: [fadeOut] +}) +@renderAuthMethodFor(AuthMethodType.Password) +export class LogInPasswordComponent implements OnInit { + + /** + * The authentication method data. + * @type {AuthMethod} + */ + public authMethod: AuthMethod; + + /** + * The error if authentication fails. + * @type {Observable} + */ + public error: Observable; + + /** + * Has authentication error. + * @type {boolean} + */ + public hasError = false; + + /** + * The authentication info message. + * @type {Observable} + */ + public message: Observable; + + /** + * Has authentication message. + * @type {boolean} + */ + public hasMessage = false; + + /** + * The authentication form. + * @type {FormGroup} + */ + public form: FormGroup; + + /** + * @constructor + * @param {AuthMethod} injectedAuthMethodModel + * @param {FormBuilder} formBuilder + * @param {Store} store + */ + constructor( + @Inject('authMethodProvider') public injectedAuthMethodModel: AuthMethod, + private formBuilder: FormBuilder, + private store: Store + ) { + this.authMethod = injectedAuthMethodModel; + } + + /** + * Lifecycle hook that is called after data-bound properties of a directive are initialized. + * @method ngOnInit + */ + public ngOnInit() { + + // set formGroup + this.form = this.formBuilder.group({ + email: ['', Validators.required], + password: ['', Validators.required] + }); + + // set error + this.error = this.store.pipe(select( + getAuthenticationError), + map((error) => { + this.hasError = (isNotEmpty(error)); + return error; + }) + ); + + // set error + this.message = this.store.pipe( + select(getAuthenticationInfo), + map((message) => { + this.hasMessage = (isNotEmpty(message)); + return message; + }) + ); + + } + + /** + * Reset error or message. + */ + public resetErrorOrMessage() { + if (this.hasError || this.hasMessage) { + this.store.dispatch(new ResetAuthenticationMessagesAction()); + this.hasError = false; + this.hasMessage = false; + } + } + + /** + * Submit the authentication form. + * @method submit + */ + public submit() { + this.resetErrorOrMessage(); + // get email and password values + const email: string = this.form.get('email').value; + const password: string = this.form.get('password').value; + + // trim values + email.trim(); + password.trim(); + + // dispatch AuthenticationAction + this.store.dispatch(new AuthenticateAction(email, password)); + + // clear form + this.form.reset(); + } + +} diff --git a/src/app/shared/log-in/methods/shibboleth/log-in-shibboleth.component.html b/src/app/shared/log-in/methods/shibboleth/log-in-shibboleth.component.html new file mode 100644 index 0000000000..713970f05b --- /dev/null +++ b/src/app/shared/log-in/methods/shibboleth/log-in-shibboleth.component.html @@ -0,0 +1,7 @@ + + + + + diff --git a/src/app/shared/log-in/methods/shibboleth/log-in-shibboleth.component.scss b/src/app/shared/log-in/methods/shibboleth/log-in-shibboleth.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/shared/log-in/methods/shibboleth/log-in-shibboleth.component.spec.ts b/src/app/shared/log-in/methods/shibboleth/log-in-shibboleth.component.spec.ts new file mode 100644 index 0000000000..29723d0f65 --- /dev/null +++ b/src/app/shared/log-in/methods/shibboleth/log-in-shibboleth.component.spec.ts @@ -0,0 +1,139 @@ +import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { async, ComponentFixture, inject, TestBed } from '@angular/core/testing'; +import { Store, StoreModule } from '@ngrx/store'; +import { TranslateModule } from '@ngx-translate/core'; + +import { EPerson } from '../../../../core/eperson/models/eperson.model'; +import { EPersonMock } from '../../../testing/eperson-mock'; +import { authReducer } from '../../../../core/auth/auth.reducer'; +import { AuthService } from '../../../../core/auth/auth.service'; +import { AuthServiceStub } from '../../../testing/auth-service-stub'; +import { AppState } from '../../../../app.reducer'; +import { AuthMethod } from '../../../../core/auth/models/auth.method'; +import { AuthMethodType } from '../../../../core/auth/models/auth.method-type'; +import { LogInShibbolethComponent } from './log-in-shibboleth.component'; +import { NativeWindowService } from '../../../../core/services/window.service'; +import { RouterStub } from '../../../testing/router-stub'; +import { ActivatedRouteStub } from '../../../testing/active-router-stub'; +import { NativeWindowMockFactory } from '../../../mocks/mock-native-window-ref'; + +describe('LogInShibbolethComponent', () => { + + let component: LogInShibbolethComponent; + let fixture: ComponentFixture; + let page: Page; + let user: EPerson; + let componentAsAny: any; + let setHrefSpy; + const shibbolethBaseUrl = 'dspace-rest.test/shibboleth?redirectUrl='; + const location = shibbolethBaseUrl + 'http://dspace-angular.test/home'; + + const authState = { + authenticated: false, + loaded: false, + loading: false, + }; + + beforeEach(() => { + user = EPersonMock; + }); + + beforeEach(async(() => { + // refine the test module by declaring the test component + TestBed.configureTestingModule({ + imports: [ + StoreModule.forRoot(authReducer), + TranslateModule.forRoot() + ], + declarations: [ + LogInShibbolethComponent + ], + providers: [ + { provide: AuthService, useClass: AuthServiceStub }, + { provide: 'authMethodProvider', useValue: new AuthMethod(AuthMethodType.Shibboleth, location) }, + { provide: NativeWindowService, useFactory: NativeWindowMockFactory }, + { provide: Router, useValue: new RouterStub() }, + { provide: ActivatedRoute, useValue: new ActivatedRouteStub() }, + ], + schemas: [ + CUSTOM_ELEMENTS_SCHEMA + ] + }) + .compileComponents(); + + })); + + beforeEach(inject([Store], (store: Store) => { + store + .subscribe((state) => { + (state as any).core = Object.create({}); + (state as any).core.auth = authState; + }); + + // create component and test fixture + fixture = TestBed.createComponent(LogInShibbolethComponent); + + // get test component from the fixture + component = fixture.componentInstance; + componentAsAny = component; + + // create page + page = new Page(component, fixture); + setHrefSpy = spyOnProperty(componentAsAny._window.nativeWindow.location, 'href', 'set').and.callThrough(); + + })); + + it('should set the properly a new redirectUrl', () => { + const currentUrl = 'http://dspace-angular.test/collections/12345'; + componentAsAny._window.nativeWindow.location.href = currentUrl; + + fixture.detectChanges(); + + expect(componentAsAny.injectedAuthMethodModel.location).toBe(location); + expect(componentAsAny._window.nativeWindow.location.href).toBe(currentUrl); + + component.redirectToShibboleth(); + + expect(setHrefSpy).toHaveBeenCalledWith(shibbolethBaseUrl + currentUrl) + + }); + + it('should not set a new redirectUrl', () => { + const currentUrl = 'http://dspace-angular.test/home'; + componentAsAny._window.nativeWindow.location.href = currentUrl; + + fixture.detectChanges(); + + expect(componentAsAny.injectedAuthMethodModel.location).toBe(location); + expect(componentAsAny._window.nativeWindow.location.href).toBe(currentUrl); + + component.redirectToShibboleth(); + + expect(setHrefSpy).toHaveBeenCalledWith(shibbolethBaseUrl + currentUrl) + + }); + +}); + +/** + * I represent the DOM elements and attach spies. + * + * @class Page + */ +class Page { + + public emailInput: HTMLInputElement; + public navigateSpy: jasmine.Spy; + public passwordInput: HTMLInputElement; + + constructor(private component: LogInShibbolethComponent, private fixture: ComponentFixture) { + // use injector to get services + const injector = fixture.debugElement.injector; + const store = injector.get(Store); + + // add spies + this.navigateSpy = spyOn(store, 'dispatch'); + } + +} diff --git a/src/app/shared/log-in/methods/shibboleth/log-in-shibboleth.component.ts b/src/app/shared/log-in/methods/shibboleth/log-in-shibboleth.component.ts new file mode 100644 index 0000000000..6321e6119f --- /dev/null +++ b/src/app/shared/log-in/methods/shibboleth/log-in-shibboleth.component.ts @@ -0,0 +1,95 @@ +import { Component, Inject, OnInit, } from '@angular/core'; + +import { Observable } from 'rxjs'; +import { select, Store } from '@ngrx/store'; + +import { renderAuthMethodFor } from '../log-in.methods-decorator'; +import { AuthMethodType } from '../../../../core/auth/models/auth.method-type'; +import { AuthMethod } from '../../../../core/auth/models/auth.method'; + +import { CoreState } from '../../../../core/core.reducers'; +import { isAuthenticated, isAuthenticationLoading } from '../../../../core/auth/selectors'; +import { RouteService } from '../../../../core/services/route.service'; +import { NativeWindowRef, NativeWindowService } from '../../../../core/services/window.service'; +import { isNotNull } from '../../../empty.util'; + +@Component({ + selector: 'ds-log-in-shibboleth', + templateUrl: './log-in-shibboleth.component.html', + styleUrls: ['./log-in-shibboleth.component.scss'], + +}) +@renderAuthMethodFor(AuthMethodType.Shibboleth) +export class LogInShibbolethComponent implements OnInit { + + /** + * The authentication method data. + * @type {AuthMethod} + */ + public authMethod: AuthMethod; + + /** + * True if the authentication is loading. + * @type {boolean} + */ + public loading: Observable; + + /** + * The shibboleth authentication location url. + * @type {string} + */ + public location: string; + + /** + * Whether user is authenticated. + * @type {Observable} + */ + public isAuthenticated: Observable; + + /** + * @constructor + * @param {AuthMethod} injectedAuthMethodModel + * @param {NativeWindowRef} _window + * @param {RouteService} route + * @param {Store} store + */ + constructor( + @Inject('authMethodProvider') public injectedAuthMethodModel: AuthMethod, + @Inject(NativeWindowService) protected _window: NativeWindowRef, + private route: RouteService, + private store: Store + ) { + this.authMethod = injectedAuthMethodModel; + } + + ngOnInit(): void { + // set isAuthenticated + this.isAuthenticated = this.store.pipe(select(isAuthenticated)); + + // set loading + this.loading = this.store.pipe(select(isAuthenticationLoading)); + + // set location + this.location = decodeURIComponent(this.injectedAuthMethodModel.location); + + } + + redirectToShibboleth() { + let newLocationUrl = this.location; + const currentUrl = this._window.nativeWindow.location.href; + const myRegexp = /\?redirectUrl=(.*)/g; + const match = myRegexp.exec(this.location); + const redirectUrl = (match && match[1]) ? match[1] : null; + + // Check whether the current page is different from the redirect url received from rest + if (isNotNull(redirectUrl) && redirectUrl !== currentUrl) { + // change the redirect url with the current page url + const newRedirectUrl = `?redirectUrl=${currentUrl}`; + newLocationUrl = this.location.replace(/\?redirectUrl=(.*)/g, newRedirectUrl); + } + + // redirect to shibboleth authentication url + this._window.nativeWindow.location.href = newLocationUrl; + } + +} diff --git a/src/app/shared/log-out/log-out.component.scss b/src/app/shared/log-out/log-out.component.scss index dcd67e092f..1514130db6 100644 --- a/src/app/shared/log-out/log-out.component.scss +++ b/src/app/shared/log-out/log-out.component.scss @@ -1 +1 @@ -@import '../log-in/log-in.component.scss'; +@import '../log-in/methods/password/log-in-password.component'; diff --git a/src/app/shared/mocks/mock-native-window-ref.ts b/src/app/shared/mocks/mock-native-window-ref.ts new file mode 100644 index 0000000000..5546bd5ccc --- /dev/null +++ b/src/app/shared/mocks/mock-native-window-ref.ts @@ -0,0 +1,21 @@ +export const MockWindow = { + location: { + _href: '', + set href(url: string) { + this._href = url; + }, + get href() { + return this._href; + } + } +}; + +export class NativeWindowRefMock { + get nativeWindow(): any { + return MockWindow; + } +} + +export function NativeWindowMockFactory() { + return new NativeWindowRefMock(); +} diff --git a/src/app/shared/pagination/pagination.component.ts b/src/app/shared/pagination/pagination.component.ts index a28ff01252..ae7c917e68 100644 --- a/src/app/shared/pagination/pagination.component.ts +++ b/src/app/shared/pagination/pagination.component.ts @@ -99,6 +99,13 @@ export class PaginationComponent implements OnDestroy, OnInit { */ @Input() public hidePagerWhenSinglePage = true; + /** + * Option for disabling updating and reading route parameters on pagination changes + * In other words, changing pagination won't add or update the url parameters on the current page, and the url + * parameters won't affect the pagination of this component + */ + @Input() public disableRouteParameterUpdate = false; + /** * Current page. */ @@ -173,20 +180,35 @@ export class PaginationComponent implements OnDestroy, OnInit { this.checkConfig(this.paginationOptions); this.initializeConfig(); // Listen to changes - this.subs.push(this.route.queryParams - .subscribe((queryParams) => { - if (this.isEmptyPaginationParams(queryParams)) { - this.initializeConfig(queryParams); + if (!this.disableRouteParameterUpdate) { + this.subs.push(this.route.queryParams + .subscribe((queryParams) => { + this.initializeParams(queryParams); + })); + } + } + + /** + * Initialize the route and current parameters + * This method will fix any invalid or missing parameters + * @param params + */ + private initializeParams(params) { + if (this.isEmptyPaginationParams(params)) { + this.initializeConfig(params); + } else { + this.currentQueryParams = params; + const fixedProperties = this.validateParams(params); + if (isNotEmpty(fixedProperties)) { + if (!this.disableRouteParameterUpdate) { + this.fixRoute(fixedProperties); } else { - this.currentQueryParams = queryParams; - const fixedProperties = this.validateParams(queryParams); - if (isNotEmpty(fixedProperties)) { - this.fixRoute(fixedProperties); - } else { - this.setFields(); - } + this.initializeParams(fixedProperties); } - })); + } else { + this.setFields(); + } + } } private fixRoute(fixedProperties) { @@ -247,7 +269,7 @@ export class PaginationComponent implements OnDestroy, OnInit { * The page being navigated to. */ public doPageChange(page: number) { - this.updateRoute({ pageId: this.id, page: page.toString() }); + this.updateParams(Object.assign({}, this.currentQueryParams, { pageId: this.id, page: page.toString() })); } /** @@ -257,7 +279,7 @@ export class PaginationComponent implements OnDestroy, OnInit { * The page size being navigated to. */ public doPageSizeChange(pageSize: number) { - this.updateRoute({ pageId: this.id, page: 1, pageSize: pageSize }); + this.updateParams(Object.assign({}, this.currentQueryParams,{ pageId: this.id, page: 1, pageSize: pageSize })); } /** @@ -267,7 +289,7 @@ export class PaginationComponent implements OnDestroy, OnInit { * The sort direction being navigated to. */ public doSortDirectionChange(sortDirection: SortDirection) { - this.updateRoute({ pageId: this.id, page: 1, sortDirection: sortDirection }); + this.updateParams(Object.assign({}, this.currentQueryParams,{ pageId: this.id, page: 1, sortDirection: sortDirection })); } /** @@ -277,7 +299,7 @@ export class PaginationComponent implements OnDestroy, OnInit { * The sort field being navigated to. */ public doSortFieldChange(field: string) { - this.updateRoute({ pageId: this.id, page: 1, sortField: field }); + this.updateParams(Object.assign(this.currentQueryParams,{ pageId: this.id, page: 1, sortField: field })); } /** @@ -347,6 +369,20 @@ export class PaginationComponent implements OnDestroy, OnInit { }) } + /** + * Update the current query params and optionally update the route + * @param params + */ + private updateParams(params: {}) { + if (isNotEmpty(difference(params, this.currentQueryParams))) { + if (!this.disableRouteParameterUpdate) { + this.updateRoute(params); + } else { + this.initializeParams(params); + } + } + } + /** * Method to update the route parameters */ diff --git a/src/app/shared/shared.module.ts b/src/app/shared/shared.module.ts index ac2520dd28..014353bdad 100644 --- a/src/app/shared/shared.module.ts +++ b/src/app/shared/shared.module.ts @@ -42,13 +42,15 @@ import { SearchResultGridElementComponent } from './object-grid/search-result-gr import { ViewModeSwitchComponent } from './view-mode-switch/view-mode-switch.component'; import { GridThumbnailComponent } from './object-grid/grid-thumbnail/grid-thumbnail.component'; import { VarDirective } from './utils/var.directive'; -import { LogInComponent } from './log-in/log-in.component'; import { AuthNavMenuComponent } from './auth-nav-menu/auth-nav-menu.component'; import { LogOutComponent } from './log-out/log-out.component'; import { FormComponent } from './form/form.component'; import { DsDynamicTypeaheadComponent } from './form/builder/ds-dynamic-form-ui/models/typeahead/dynamic-typeahead.component'; import { DsDynamicScrollableDropdownComponent } from './form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component'; -import { DsDynamicFormControlContainerComponent, dsDynamicFormControlMapFn } from './form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component'; +import { + DsDynamicFormControlContainerComponent, + dsDynamicFormControlMapFn +} from './form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component'; import { DsDynamicFormComponent } from './form/builder/ds-dynamic-form-ui/ds-dynamic-form.component'; import { DYNAMIC_FORM_CONTROL_MAP_FN, DynamicFormsCoreModule } from '@ng-dynamic-forms/core'; import { DynamicFormsNGBootstrapUIModule } from '@ng-dynamic-forms/ui-ng-bootstrap'; @@ -176,8 +178,14 @@ import { ExternalSourceEntryImportModalComponent } from './form/builder/ds-dynam import { ImportableListItemControlComponent } from './object-collection/shared/importable-list-item-control/importable-list-item-control.component'; import { DragDropModule } from '@angular/cdk/drag-drop'; import { ExistingMetadataListElementComponent } from './form/builder/ds-dynamic-form-ui/existing-metadata-list-element/existing-metadata-list-element.component'; +import { ItemVersionsComponent } from './item/item-versions/item-versions.component'; import { SortablejsModule } from 'ngx-sortablejs'; +import { LogInContainerComponent } from './log-in/container/log-in-container.component'; +import { LogInShibbolethComponent } from './log-in/methods/shibboleth/log-in-shibboleth.component'; +import { LogInPasswordComponent } from './log-in/methods/password/log-in-password.component'; +import { LogInComponent } from './log-in/log-in.component'; import { MissingTranslationHelper } from './translate/missing-translation.helper'; +import { ItemVersionsNoticeComponent } from './item/item-versions/notice/item-versions-notice.component'; const MODULES = [ // Do NOT include UniversalModule, HttpModule, or JsonpModule here @@ -344,7 +352,12 @@ const COMPONENTS = [ ExternalSourceEntryImportModalComponent, ImportableListItemControlComponent, ExistingMetadataListElementComponent, + LogInShibbolethComponent, + LogInPasswordComponent, + LogInContainerComponent, + ItemVersionsComponent, PublicationSearchResultListElementComponent, + ItemVersionsNoticeComponent ]; const ENTRY_COMPONENTS = [ @@ -408,6 +421,10 @@ const ENTRY_COMPONENTS = [ DsDynamicLookupRelationSelectionTabComponent, DsDynamicLookupRelationExternalSourceTabComponent, ExternalSourceEntryImportModalComponent, + LogInPasswordComponent, + LogInShibbolethComponent, + ItemVersionsComponent, + ItemVersionsNoticeComponent ]; const SHARED_ITEM_PAGE_COMPONENTS = [ diff --git a/src/app/shared/testing/auth-request-service.stub.ts b/src/app/shared/testing/auth-request-service.stub.ts index d619ac0de6..1ff8cba23e 100644 --- a/src/app/shared/testing/auth-request-service.stub.ts +++ b/src/app/shared/testing/auth-request-service.stub.ts @@ -23,7 +23,7 @@ export class AuthRequestServiceStub { } else { authStatusStub.authenticated = false; } - } else { + } else if (isNotEmpty(options)) { const token = (options.headers as any).lazyUpdate[1].value; if (this.validateToken(token)) { authStatusStub.authenticated = true; @@ -32,6 +32,8 @@ export class AuthRequestServiceStub { } else { authStatusStub.authenticated = false; } + } else { + authStatusStub.authenticated = false; } return observableOf(authStatusStub); } @@ -43,7 +45,7 @@ export class AuthRequestServiceStub { authStatusStub.authenticated = false; break; case 'status': - const token = (options.headers as any).lazyUpdate[1].value; + const token = ((options.headers as any).lazyUpdate[1]) ? (options.headers as any).lazyUpdate[1].value : null; if (this.validateToken(token)) { authStatusStub.authenticated = true; authStatusStub.token = this.mockTokenInfo; diff --git a/src/app/shared/testing/auth-service.stub.ts b/src/app/shared/testing/auth-service.stub.ts index a8711a7f96..5c2d048740 100644 --- a/src/app/shared/testing/auth-service.stub.ts +++ b/src/app/shared/testing/auth-service.stub.ts @@ -4,6 +4,12 @@ import { AuthTokenInfo } from '../../core/auth/models/auth-token-info.model'; 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'; + +export const authMethodsMock = [ + new AuthMethod('password'), + new AuthMethod('shibboleth', 'dspace.test/shibboleth') +]; export class AuthServiceStub { @@ -106,4 +112,12 @@ export class AuthServiceStub { isAuthenticated() { return observableOf(true); } + + checkAuthenticationCookie() { + return; + } + + retrieveAuthMethodsFromAuthStatus(status: AuthStatus) { + return observableOf(authMethodsMock); + } } diff --git a/src/assets/i18n/en.json5 b/src/assets/i18n/en.json5 index 5c53386c6c..ec78dbed6b 100644 --- a/src/assets/i18n/en.json5 +++ b/src/assets/i18n/en.json5 @@ -1005,6 +1005,12 @@ "item.edit.tabs.status.title": "Item Edit - Status", + "item.edit.tabs.versionhistory.head": "Version History", + + "item.edit.tabs.versionhistory.title": "Item Edit - Version History", + + "item.edit.tabs.versionhistory.under-construction": "Editing or adding new versions is not yet possible in this user interface.", + "item.edit.tabs.view.head": "View Item", "item.edit.tabs.view.title": "Item Edit - View", @@ -1084,6 +1090,29 @@ "item.select.table.title": "Title", + "item.version.history.empty": "There are no other versions for this item yet.", + + "item.version.history.head": "Version History", + + "item.version.history.return": "Return", + + "item.version.history.selected": "Selected version", + + "item.version.history.table.version": "Version", + + "item.version.history.table.item": "Item", + + "item.version.history.table.editor": "Editor", + + "item.version.history.table.date": "Date", + + "item.version.history.table.summary": "Summary", + + + + "item.version.notice": "This is not the latest version of this item. The latest version can be found here.", + + "journal.listelement.badge": "Journal", @@ -1175,8 +1204,12 @@ "login.form.new-user": "New user? Click here to register.", + "login.form.or-divider": "or", + "login.form.password": "Password", + "login.form.shibboleth": "Log in with Shibboleth", + "login.form.submit": "Log in", "login.title": "Login", diff --git a/src/assets/i18n/pt.json5 b/src/assets/i18n/pt.json5 index 15f0608520..ba3d547546 100644 --- a/src/assets/i18n/pt.json5 +++ b/src/assets/i18n/pt.json5 @@ -918,7 +918,7 @@ "item.edit.move.processing": "Movendo...", // "item.edit.move.search.placeholder": "Enter a search query to look for collections", - "item.edit.move.search.placeholder": "nsira uma consulta para procurar coleções", + "item.edit.move.search.placeholder": "Insira uma consulta para procurar coleções", // "item.edit.move.success": "The item has been moved successfully", "item.edit.move.success": "O item foi movido com sucesso", diff --git a/src/config/auth-config.interfaces.ts b/src/config/auth-config.interfaces.ts new file mode 100644 index 0000000000..cc3d97c6b8 --- /dev/null +++ b/src/config/auth-config.interfaces.ts @@ -0,0 +1,10 @@ +import { Config } from './config.interface'; + +export interface AuthTarget { + host: string; + page: string; +} + +export interface AuthConfig extends Config { + target: AuthTarget; +} diff --git a/src/config/global-config.interface.ts b/src/config/global-config.interface.ts index 2a7c1718ef..a368e850c2 100644 --- a/src/config/global-config.interface.ts +++ b/src/config/global-config.interface.ts @@ -10,12 +10,14 @@ import { BrowseByConfig } from './browse-by-config.interface'; import { ItemPageConfig } from './item-page-config.interface'; import { CollectionPageConfig } from './collection-page-config.interface'; import { Theme } from './theme.inferface'; +import {AuthConfig} from './auth-config.interfaces'; export interface GlobalConfig extends Config { ui: ServerConfig; rest: ServerConfig; production: boolean; cache: CacheConfig; + auth: AuthConfig; form: FormConfig; notifications: INotificationBoardOptions; submission: SubmissionConfig;