diff --git a/src/app/+admin/admin-sidebar/expandable-admin-sidebar-section/expandable-admin-sidebar-section.component.scss b/src/app/+admin/admin-sidebar/expandable-admin-sidebar-section/expandable-admin-sidebar-section.component.scss index 37fe15bd40..1f6e288608 100644 --- a/src/app/+admin/admin-sidebar/expandable-admin-sidebar-section/expandable-admin-sidebar-section.component.scss +++ b/src/app/+admin/admin-sidebar/expandable-admin-sidebar-section/expandable-admin-sidebar-section.component.scss @@ -1,4 +1,4 @@ -::ng-deep { +:host ::ng-deep { .fa-chevron-right { padding-left: $spacer/2; font-size: 0.5rem; @@ -16,4 +16,4 @@ display: flex; flex-direction: column; } -} \ No newline at end of file +} diff --git a/src/app/+item-page/edit-item-page/item-bitstreams/item-bitstreams.component.spec.ts b/src/app/+item-page/edit-item-page/item-bitstreams/item-bitstreams.component.spec.ts index 5aa085a42c..125fc1fb0c 100644 --- a/src/app/+item-page/edit-item-page/item-bitstreams/item-bitstreams.component.spec.ts +++ b/src/app/+item-page/edit-item-page/item-bitstreams/item-bitstreams.component.spec.ts @@ -191,15 +191,15 @@ describe('ItemBitstreamsComponent', () => { }); describe('when dropBitstream is called', () => { - const event = { - fromIndex: 0, - toIndex: 50, - // tslint:disable-next-line:no-empty - finish: () => {} - }; - - beforeEach(() => { - comp.dropBitstream(bundle, event); + beforeEach((done) => { + comp.dropBitstream(bundle, { + fromIndex: 0, + toIndex: 50, + // tslint:disable-next-line:no-empty + finish: () => { + done(); + } + }) }); it('should send out a patch for the move operation', () => { diff --git a/src/app/+item-page/full/field-components/file-section/full-file-section.component.html b/src/app/+item-page/full/field-components/file-section/full-file-section.component.html index b8ab9bdb41..7c1719eb82 100644 --- a/src/app/+item-page/full/field-components/file-section/full-file-section.component.html +++ b/src/app/+item-page/full/field-components/file-section/full-file-section.component.html @@ -21,9 +21,9 @@
- + {{"item.page.filesection.download" | translate}} - +
diff --git a/src/app/+item-page/simple/field-components/file-section/file-section.component.html b/src/app/+item-page/simple/field-components/file-section/file-section.component.html index 6533322e03..17e4a795e7 100644 --- a/src/app/+item-page/simple/field-components/file-section/file-section.component.html +++ b/src/app/+item-page/simple/field-components/file-section/file-section.component.html @@ -1,11 +1,11 @@ diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts index 655f0f94fc..c88342b3bf 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -45,6 +45,20 @@ export function getProfileModulePath() { return `/${PROFILE_MODULE_PATH}`; } +const REGISTER_PATH = 'register'; + +export function getRegisterPath() { + return `/${REGISTER_PATH}`; + +} + +const FORGOT_PASSWORD_PATH = 'forgot'; + +export function getForgotPasswordPath() { + return `/${FORGOT_PASSWORD_PATH}`; + +} + const WORKFLOW_ITEM_MODULE_PATH = 'workflowitems'; export function getWorkflowItemModulePath() { @@ -71,6 +85,8 @@ export function getDSOPath(dso: DSpaceObject): string { { path: 'community-list', loadChildren: './community-list-page/community-list-page.module#CommunityListPageModule' }, { path: 'id', loadChildren: './+lookup-by-id/lookup-by-id.module#LookupIdModule' }, { path: 'handle', loadChildren: './+lookup-by-id/lookup-by-id.module#LookupIdModule' }, + { path: REGISTER_PATH, loadChildren: './register-page/register-page.module#RegisterPageModule' }, + { path: FORGOT_PASSWORD_PATH, loadChildren: './forgot-password/forgot-password.module#ForgotPasswordModule' }, { 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' }, diff --git a/src/app/community-list-page/community-list-datasource.ts b/src/app/community-list-page/community-list-datasource.ts index 3a9d9f2077..b77cbb5246 100644 --- a/src/app/community-list-page/community-list-datasource.ts +++ b/src/app/community-list-page/community-list-datasource.ts @@ -1,3 +1,5 @@ +import { NgZone } from '@angular/core'; +import { FindListOptions } from '../core/data/request.models'; import { CommunityListService, FlatNode } from './community-list-service'; import { CollectionViewer, DataSource } from '@angular/cdk/typings/collections'; import { BehaviorSubject, Observable, } from 'rxjs'; @@ -14,21 +16,23 @@ export class CommunityListDatasource implements DataSource { private communityList$ = new BehaviorSubject([]); public loading$ = new BehaviorSubject(false); - constructor(private communityListService: CommunityListService) { + constructor(private communityListService: CommunityListService, + private zone: NgZone) { } connect(collectionViewer: CollectionViewer): Observable { return this.communityList$.asObservable(); } - loadCommunities(expandedNodes: FlatNode[]) { + loadCommunities(findOptions: FindListOptions, expandedNodes: FlatNode[]) { this.loading$.next(true); - - this.communityListService.loadCommunities(expandedNodes).pipe( - take(1), - finalize(() => this.loading$.next(false)), - ).subscribe((flatNodes: FlatNode[]) => { - this.communityList$.next(flatNodes); + this.zone.runOutsideAngular(() => { + this.communityListService.loadCommunities(findOptions, expandedNodes).pipe( + take(1), + finalize(() => this.zone.run(() => this.loading$.next(false))), + ).subscribe((flatNodes: FlatNode[]) => { + this.zone.run(() => this.communityList$.next(flatNodes)); + }); }); } diff --git a/src/app/community-list-page/community-list-service.spec.ts b/src/app/community-list-page/community-list-service.spec.ts index 6b7ab2bd77..accd0f23a5 100644 --- a/src/app/community-list-page/community-list-service.spec.ts +++ b/src/app/community-list-page/community-list-service.spec.ts @@ -1,21 +1,19 @@ -import { of as observableOf } from 'rxjs'; -import { TestBed, inject, async } from '@angular/core/testing'; +import { inject, TestBed } from '@angular/core/testing'; import { Store } from '@ngrx/store'; +import { of as observableOf } from 'rxjs'; +import { take } from 'rxjs/operators'; import { AppState } from '../app.reducer'; +import { SortDirection, SortOptions } from '../core/cache/models/sort-options.model'; +import { PaginatedList } from '../core/data/paginated-list'; +import { createFailedRemoteDataObject$, createSuccessfulRemoteDataObject$ } from '../shared/remote-data.utils'; import { StoreMock } from '../shared/testing/store.mock'; import { CommunityListService, FlatNode, toFlatNode } from './community-list-service'; import { CollectionDataService } from '../core/data/collection-data.service'; -import { PaginatedList } from '../core/data/paginated-list'; -import { PageInfo } from '../core/shared/page-info.model'; import { CommunityDataService } from '../core/data/community-data.service'; -import { - createFailedRemoteDataObject$, - createSuccessfulRemoteDataObject$ -} from '../shared/remote-data.utils'; import { Community } from '../core/shared/community.model'; import { Collection } from '../core/shared/collection.model'; -import { take } from 'rxjs/operators'; import { FindListOptions } from '../core/data/request.models'; +import { PageInfo } from '../core/shared/page-info.model'; describe('CommunityListService', () => { let store: StoreMock; @@ -210,13 +208,18 @@ describe('CommunityListService', () => { let flatNodeList; describe('None expanded: should return list containing only flatnodes of the test top communities page 1 and 2', () => { let findTopSpy; - beforeEach(() => { + beforeEach((done) => { findTopSpy = spyOn(communityDataServiceStub, 'findTop').and.callThrough(); - service.getNextPageTopCommunities(); - const sub = service.loadCommunities(null) - .subscribe((value) => flatNodeList = value); - sub.unsubscribe(); + service.loadCommunities({ + currentPage: 2, + sort: new SortOptions('dc.title', SortDirection.ASC) + }, null) + .pipe(take(1)) + .subscribe((value) => { + flatNodeList = value; + done(); + }); }); it('flatnode list should contain just flatnodes of top community list page 1 and 2', () => { expect(findTopSpy).toHaveBeenCalled(); @@ -236,10 +239,16 @@ describe('CommunityListService', () => { describe('should transform all communities in a list of flatnodes with possible subcoms and collections as subflatnodes if they\'re expanded', () => { let flatNodeList; describe('None expanded: should return list containing only flatnodes of the test top communities', () => { - beforeEach(() => { - const sub = service.loadCommunities(null) - .pipe(take(1)).subscribe((value) => flatNodeList = value); - sub.unsubscribe(); + beforeEach((done) => { + service.loadCommunities({ + currentPage: 1, + sort: new SortOptions('dc.title', SortDirection.ASC) + }, null) + .pipe(take(1)) + .subscribe((value) => { + flatNodeList = value; + done(); + }); }); it('length of flatnode list should be as big as top community list', () => { expect(flatNodeList.length).toEqual(mockListOfTopCommunitiesPage1.length); @@ -256,7 +265,7 @@ describe('CommunityListService', () => { }); }); describe('All top expanded, all page 1: should return list containing flatnodes of the communities in the test list and all its possible page-limited children (subcommunities and collections)', () => { - beforeEach(() => { + beforeEach((done) => { const expandedNodes = []; mockListOfTopCommunitiesPage1.map((community: Community) => { const communityFlatNode = toFlatNode(community, observableOf(true), 0, true, null); @@ -264,9 +273,15 @@ describe('CommunityListService', () => { communityFlatNode.currentCommunityPage = 1; expandedNodes.push(communityFlatNode); }); - const sub = service.loadCommunities(expandedNodes) - .pipe(take(1)).subscribe((value) => flatNodeList = value); - sub.unsubscribe(); + service.loadCommunities({ + currentPage: 1, + sort: new SortOptions('dc.title', SortDirection.ASC) + }, expandedNodes) + .pipe(take(1)) + .subscribe((value) => { + flatNodeList = value; + done(); + }); }); it('length of flatnode list should be as big as top community list and size of its possible page-limited children', () => { expect(flatNodeList.length).toEqual(mockListOfTopCommunitiesPage1.length + mockSubcommunities1Page1.length + mockSubcommunities1Page1.length); @@ -281,14 +296,20 @@ describe('CommunityListService', () => { }); }); describe('Just first top comm expanded, all page 1: should return list containing flatnodes of the communities in the test list and all its possible page-limited children (subcommunities and collections)', () => { - beforeEach(() => { + beforeEach((done) => { const communityFlatNode = toFlatNode(mockListOfTopCommunitiesPage1[0], observableOf(true), 0, true, null); communityFlatNode.currentCollectionPage = 1; communityFlatNode.currentCommunityPage = 1; const expandedNodes = [communityFlatNode]; - const sub = service.loadCommunities(expandedNodes) - .pipe(take(1)).subscribe((value) => flatNodeList = value); - sub.unsubscribe(); + service.loadCommunities({ + currentPage: 1, + sort: new SortOptions('dc.title', SortDirection.ASC) + }, expandedNodes) + .pipe(take(1)) + .subscribe((value) => { + flatNodeList = value; + done(); + }); }); it('length of flatnode list should be as big as top community list and size of page-limited children of first top community', () => { expect(flatNodeList.length).toEqual(mockListOfTopCommunitiesPage1.length + mockSubcommunities1Page1.length); @@ -300,14 +321,20 @@ describe('CommunityListService', () => { }); }); describe('Just second top comm expanded, collections at page 2: should return list containing flatnodes of the communities in the test list and all its possible page-limited children (subcommunities and collections)', () => { - beforeEach(() => { + beforeEach((done) => { const communityFlatNode = toFlatNode(mockListOfTopCommunitiesPage1[1], observableOf(true), 0, true, null); communityFlatNode.currentCollectionPage = 2; communityFlatNode.currentCommunityPage = 1; const expandedNodes = [communityFlatNode]; - const sub = service.loadCommunities(expandedNodes) - .pipe(take(1)).subscribe((value) => flatNodeList = value); - sub.unsubscribe(); + service.loadCommunities({ + currentPage: 1, + sort: new SortOptions('dc.title', SortDirection.ASC) + }, expandedNodes) + .pipe(take(1)) + .subscribe((value) => { + flatNodeList = value; + done(); + }); }); it('length of flatnode list should be as big as top community list and size of page-limited children of second top community', () => { expect(flatNodeList.length).toEqual(mockListOfTopCommunitiesPage1.length + mockCollectionsPage1.length + mockCollectionsPage2.length); @@ -333,10 +360,13 @@ describe('CommunityListService', () => { }); let flatNodeList; describe('None expanded: should return list containing only flatnodes of the communities in the test list', () => { - beforeEach(() => { - const sub = service.transformListOfCommunities(new PaginatedList(new PageInfo(), listOfCommunities), 0, null, null) - .pipe(take(1)).subscribe((value) => flatNodeList = value); - sub.unsubscribe(); + beforeEach((done) => { + service.transformListOfCommunities(new PaginatedList(new PageInfo(), listOfCommunities), 0, null, null) + .pipe(take(1)) + .subscribe((value) => { + flatNodeList = value; + done(); + }); }); it('length of flatnode list should be as big as community test list', () => { expect(flatNodeList.length).toEqual(listOfCommunities.length); @@ -353,7 +383,7 @@ describe('CommunityListService', () => { }); }); describe('All top expanded, all page 1: should return list containing flatnodes of the communities in the test list and all its possible page-limited children (subcommunities and collections)', () => { - beforeEach(() => { + beforeEach((done) => { const expandedNodes = []; listOfCommunities.map((community: Community) => { const communityFlatNode = toFlatNode(community, observableOf(true), 0, true, null); @@ -361,9 +391,12 @@ describe('CommunityListService', () => { communityFlatNode.currentCommunityPage = 1; expandedNodes.push(communityFlatNode); }); - const sub = service.transformListOfCommunities(new PaginatedList(new PageInfo(), listOfCommunities), 0, null, expandedNodes) - .pipe(take(1)).subscribe((value) => flatNodeList = value); - sub.unsubscribe(); + service.transformListOfCommunities(new PaginatedList(new PageInfo(), listOfCommunities), 0, null, expandedNodes) + .pipe(take(1)) + .subscribe((value) => { + flatNodeList = value; + done(); + }); }); it('length of flatnode list should be as big as community test list and size of its possible children', () => { expect(flatNodeList.length).toEqual(listOfCommunities.length + mockSubcommunities1Page1.length + mockSubcommunities1Page1.length); @@ -397,10 +430,13 @@ describe('CommunityListService', () => { }); let flatNodeList; describe('should return list containing only flatnode corresponding to that community', () => { - beforeEach(() => { - const sub = service.transformCommunity(communityWithNoSubcomsOrColls, 0, null, null) - .pipe(take(1)).subscribe((value) => flatNodeList = value); - sub.unsubscribe(); + beforeEach((done) => { + service.transformCommunity(communityWithNoSubcomsOrColls, 0, null, null) + .pipe(take(1)) + .subscribe((value) => { + flatNodeList = value; + done(); + }); }); it('length of flatnode list should be 1', () => { expect(flatNodeList.length).toEqual(1); @@ -426,10 +462,14 @@ describe('CommunityListService', () => { }); let flatNodeList; describe('should return list containing only flatnode corresponding to that community', () => { - beforeAll(() => { - const sub = service.transformCommunity(communityWithSubcoms, 0, null, null) - .pipe(take(1)).subscribe((value) => flatNodeList = value); - sub.unsubscribe(); + beforeAll((done) => { + service.transformCommunity(communityWithSubcoms, 0, null, null) + .pipe(take(1)) + .subscribe((value) => { + flatNodeList = value; + done(); + }); + }); it('length of flatnode list should be 1', () => { expect(flatNodeList.length).toEqual(1); @@ -455,14 +495,17 @@ describe('CommunityListService', () => { } }); let flatNodeList; - beforeEach(() => { + beforeEach((done) => { const communityFlatNode = toFlatNode(communityWithSubcoms, observableOf(true), 0, true, null); communityFlatNode.currentCollectionPage = 1; communityFlatNode.currentCommunityPage = 1; const expandedNodes = [communityFlatNode]; - const sub = service.transformCommunity(communityWithSubcoms, 0, null, expandedNodes) - .pipe(take(1)).subscribe((value) => flatNodeList = value); - sub.unsubscribe(); + service.transformCommunity(communityWithSubcoms, 0, null, expandedNodes) + .pipe(take(1)) + .subscribe((value) => { + flatNodeList = value; + done(); + }); }); it('list of flatnodes is length is 1 + nrOfSubcoms & first flatnode is of expanded test community', () => { expect(flatNodeList.length).toEqual(1 + mockSubcommunities1Page1.length); @@ -485,7 +528,7 @@ describe('CommunityListService', () => { describe('should return list containing flatnodes of that community, its collections of the first two pages', () => { let communityWithCollections; let flatNodeList; - beforeEach(() => { + beforeEach((done) => { communityWithCollections = Object.assign(new Community(), { id: '9076bd16-e69a-48d6-9e41-0238cb40d863', uuid: '9076bd16-e69a-48d6-9e41-0238cb40d863', @@ -500,9 +543,12 @@ describe('CommunityListService', () => { communityFlatNode.currentCollectionPage = 2; communityFlatNode.currentCommunityPage = 1; const expandedNodes = [communityFlatNode]; - const sub = service.transformCommunity(communityWithCollections, 0, null, expandedNodes) - .pipe(take(1)).subscribe((value) => flatNodeList = value); - sub.unsubscribe(); + service.transformCommunity(communityWithCollections, 0, null, expandedNodes) + .pipe(take(1)) + .subscribe((value) => { + flatNodeList = value; + done(); + }); }); it('list of flatnodes is length is 1 + nrOfCollections & first flatnode is of expanded test community', () => { expect(flatNodeList.length).toEqual(1 + mockCollectionsPage1.length + mockCollectionsPage2.length); @@ -533,7 +579,7 @@ describe('CommunityListService', () => { describe('getIsExpandable', () => { describe('should return true', () => { - it('if community has subcommunities', () => { + it('if community has subcommunities', (done) => { const communityWithSubcoms = Object.assign(new Community(), { id: '7669c72a-3f2a-451f-a3b9-9210e7a4c02f', uuid: '7669c72a-3f2a-451f-a3b9-9210e7a4c02f', @@ -546,9 +592,10 @@ describe('CommunityListService', () => { }); service.getIsExpandable(communityWithSubcoms).pipe(take(1)).subscribe((result) => { expect(result).toEqual(true); + done(); }); }); - it('if community has collections', () => { + it('if community has collections', (done) => { const communityWithCollections = Object.assign(new Community(), { id: '9076bd16-e69a-48d6-9e41-0238cb40d863', uuid: '9076bd16-e69a-48d6-9e41-0238cb40d863', @@ -561,11 +608,12 @@ describe('CommunityListService', () => { }); service.getIsExpandable(communityWithCollections).pipe(take(1)).subscribe((result) => { expect(result).toEqual(true); + done(); }); }); }); describe('should return false', () => { - it('if community has neither subcommunities nor collections', () => { + it('if community has neither subcommunities nor collections', (done) => { const communityWithNoSubcomsOrColls = Object.assign(new Community(), { id: 'efbf25e1-2d8c-4c28-8f3e-2e04c215be24', uuid: 'efbf25e1-2d8c-4c28-8f3e-2e04c215be24', @@ -578,6 +626,7 @@ describe('CommunityListService', () => { }); service.getIsExpandable(communityWithNoSubcomsOrColls).pipe(take(1)).subscribe((result) => { expect(result).toEqual(false); + done(); }); }); }); diff --git a/src/app/community-list-page/community-list-service.ts b/src/app/community-list-page/community-list-service.ts index be04887e71..a5c3506e3d 100644 --- a/src/app/community-list-page/community-list-service.ts +++ b/src/app/community-list-page/community-list-service.ts @@ -4,11 +4,12 @@ import { combineLatest as observableCombineLatest } from 'rxjs/internal/observab import { Observable, of as observableOf } from 'rxjs'; import { AppState } from '../app.reducer'; import { CommunityDataService } from '../core/data/community-data.service'; -import { PaginationComponentOptions } from '../shared/pagination/pagination-component-options.model'; -import { SortDirection, SortOptions } from '../core/cache/models/sort-options.model'; -import { catchError, filter, map, switchMap, take } from 'rxjs/operators'; +import { FindListOptions } from '../core/data/request.models'; +import { map, flatMap } from 'rxjs/operators'; import { Community } from '../core/shared/community.model'; import { Collection } from '../core/shared/collection.model'; +import { getSucceededRemoteData } from '../core/shared/operators'; +import { PageInfo } from '../core/shared/page-info.model'; import { hasValue, isNotEmpty } from '../shared/empty.util'; import { RemoteData } from '../core/data/remote-data'; import { PaginatedList } from '../core/data/paginated-list'; @@ -46,8 +47,7 @@ export class ShowMoreFlatNode { // Helper method to combine an flatten an array of observables of flatNode arrays export const combineAndFlatten = (obsList: Array>): Observable => observableCombineLatest(...obsList).pipe( - map((matrix: FlatNode[][]) => - matrix.reduce((combinedList, currentList: FlatNode[]) => [...combinedList, ...currentList])) + map((matrix: any[][]) => [].concat(...matrix)) ); /** @@ -99,6 +99,8 @@ const communityListStateSelector = (state: AppState) => state.communityList; const expandedNodesSelector = createSelector(communityListStateSelector, (communityList: CommunityListState) => communityList.expandedNodes); const loadingNodeSelector = createSelector(communityListStateSelector, (communityList: CommunityListState) => communityList.loadingNode); +export const MAX_COMCOLS_PER_PAGE = 50; + /** * Service class for the community list, responsible for the creating of the flat list used by communityList dataSource * and connection to the store to retrieve and save the state of the community list @@ -107,26 +109,8 @@ const loadingNodeSelector = createSelector(communityListStateSelector, (communit @Injectable() export class CommunityListService { - // page-limited list of top-level communities - payloads$: Array>>; - - topCommunitiesConfig: PaginationComponentOptions; - topCommunitiesSortConfig: SortOptions; - - maxSubCommunitiesPerPage: number; - maxCollectionsPerPage: number; - constructor(private communityDataService: CommunityDataService, private collectionDataService: CollectionDataService, private store: Store) { - this.topCommunitiesConfig = new PaginationComponentOptions(); - this.topCommunitiesConfig.id = 'top-level-pagination'; - this.topCommunitiesConfig.pageSize = 10; - this.topCommunitiesConfig.currentPage = 1; - this.topCommunitiesSortConfig = new SortOptions('dc.title', SortDirection.ASC); - this.initTopCommunityList(); - - this.maxSubCommunitiesPerPage = 3; - this.maxCollectionsPerPage = 3; } saveCommunityListStateToStore(expandedNodes: FlatNode[], loadingNode: FlatNode): void { @@ -141,57 +125,46 @@ export class CommunityListService { return this.store.select(loadingNodeSelector); } - /** - * Increases the payload so it contains the next page of top level communities - */ - getNextPageTopCommunities(): void { - this.topCommunitiesConfig.currentPage = this.topCommunitiesConfig.currentPage + 1; - this.payloads$ = [...this.payloads$, this.communityDataService.findTop({ - currentPage: this.topCommunitiesConfig.currentPage, - elementsPerPage: this.topCommunitiesConfig.pageSize, - sort: { - field: this.topCommunitiesSortConfig.field, - direction: this.topCommunitiesSortConfig.direction - } - }).pipe( - take(1), - map((results) => results.payload), - )]; - } - /** * Gets all top communities, limited by page, and transforms this in a list of flatNodes. * @param expandedNodes List of expanded nodes; if a node is not expanded its subCommunities and collections need * not be added to the list */ - loadCommunities(expandedNodes: FlatNode[]): Observable { - const res = this.payloads$.map((payload) => { - return payload.pipe( - take(1), - switchMap((result: PaginatedList) => { - return this.transformListOfCommunities(result, 0, null, expandedNodes); - }), - catchError(() => observableOf([])), - ); - }); - return combineAndFlatten(res); + loadCommunities(findOptions: FindListOptions, expandedNodes: FlatNode[]): Observable { + const currentPage = findOptions.currentPage; + const topCommunities = []; + for (let i = 1; i <= currentPage; i++) { + const pagination: FindListOptions = Object.assign({}, findOptions, { currentPage: i }); + topCommunities.push(this.getTopCommunities(pagination)); + } + const topComs$ = observableCombineLatest(...topCommunities).pipe( + map((coms: Array>) => { + const newPages: Community[][] = coms.map((unit: PaginatedList) => unit.page); + const newPage: Community[] = [].concat(...newPages); + let newPageInfo = new PageInfo(); + if (coms && coms.length > 0) { + newPageInfo = Object.assign({}, coms[0].pageInfo, { currentPage }) + } + return new PaginatedList(newPageInfo, newPage); + }) + ); + return topComs$.pipe(flatMap((topComs: PaginatedList) => this.transformListOfCommunities(topComs, 0, null, expandedNodes))); }; /** * Puts the initial top level communities in a list to be called upon */ - private initTopCommunityList(): void { - this.payloads$ = [this.communityDataService.findTop({ - currentPage: this.topCommunitiesConfig.currentPage, - elementsPerPage: this.topCommunitiesConfig.pageSize, + private getTopCommunities(options: FindListOptions): Observable> { + return this.communityDataService.findTop({ + currentPage: options.currentPage, + elementsPerPage: MAX_COMCOLS_PER_PAGE, sort: { - field: this.topCommunitiesSortConfig.field, - direction: this.topCommunitiesSortConfig.direction + field: options.sort.field, + direction: options.sort.direction } }).pipe( - take(1), map((results) => results.payload), - )]; + ); } /** @@ -206,16 +179,15 @@ export class CommunityListService { parent: FlatNode, expandedNodes: FlatNode[]): Observable { if (isNotEmpty(listOfPaginatedCommunities.page)) { - let currentPage = this.topCommunitiesConfig.currentPage; + let currentPage = listOfPaginatedCommunities.currentPage; if (isNotEmpty(parent)) { currentPage = expandedNodes.find((node: FlatNode) => node.id === parent.id).currentCommunityPage; } - const isNotAllCommunities = (listOfPaginatedCommunities.totalElements > (listOfPaginatedCommunities.elementsPerPage * currentPage)); let obsList = listOfPaginatedCommunities.page .map((community: Community) => { return this.transformCommunity(community, level, parent, expandedNodes) }); - if (isNotAllCommunities && listOfPaginatedCommunities.currentPage > currentPage) { + if (currentPage < listOfPaginatedCommunities.totalPages && currentPage === listOfPaginatedCommunities.currentPage) { obsList = [...obsList, observableOf([showMoreFlatNode('community', level, parent)])]; } @@ -252,13 +224,12 @@ export class CommunityListService { let subcoms = []; for (let i = 1; i <= currentCommunityPage; i++) { const nextSetOfSubcommunitiesPage = this.communityDataService.findByParent(community.uuid, { - elementsPerPage: this.maxSubCommunitiesPerPage, + elementsPerPage: MAX_COMCOLS_PER_PAGE, currentPage: i }) .pipe( - filter((rd: RemoteData>) => rd.hasSucceeded), - take(1), - switchMap((rd: RemoteData>) => + getSucceededRemoteData(), + flatMap((rd: RemoteData>) => this.transformListOfCommunities(rd.payload, level + 1, communityFlatNode, expandedNodes)) ); @@ -271,16 +242,15 @@ export class CommunityListService { let collections = []; for (let i = 1; i <= currentCollectionPage; i++) { const nextSetOfCollectionsPage = this.collectionDataService.findByParent(community.uuid, { - elementsPerPage: this.maxCollectionsPerPage, + elementsPerPage: MAX_COMCOLS_PER_PAGE, currentPage: i }) .pipe( - filter((rd: RemoteData>) => rd.hasSucceeded), - take(1), + getSucceededRemoteData(), map((rd: RemoteData>) => { let nodes = rd.payload.page .map((collection: Collection) => toFlatNode(collection, observableOf(false), level + 1, false, communityFlatNode)); - if ((rd.payload.elementsPerPage * currentCollectionPage) < rd.payload.totalElements && rd.payload.currentPage > currentCollectionPage) { + if (currentCollectionPage < rd.payload.totalPages && currentCollectionPage === rd.payload.currentPage) { nodes = [...nodes, showMoreFlatNode('collection', level + 1, communityFlatNode)]; } return nodes; @@ -305,21 +275,18 @@ export class CommunityListService { let hasColls$: Observable; hasSubcoms$ = this.communityDataService.findByParent(community.uuid, { elementsPerPage: 1 }) .pipe( - filter((rd: RemoteData>) => rd.hasSucceeded), - take(1), + getSucceededRemoteData(), map((results) => results.payload.totalElements > 0), ); hasColls$ = this.collectionDataService.findByParent(community.uuid, { elementsPerPage: 1 }) .pipe( - filter((rd: RemoteData>) => rd.hasSucceeded), - take(1), + getSucceededRemoteData(), map((results) => results.payload.totalElements > 0), ); let hasChildren$: Observable; hasChildren$ = observableCombineLatest(hasSubcoms$, hasColls$).pipe( - take(1), map(([hasSubcoms, hasColls]: [boolean, boolean]) => { if (hasSubcoms || hasColls) { return true; diff --git a/src/app/community-list-page/community-list/community-list.component.spec.ts b/src/app/community-list-page/community-list/community-list.component.spec.ts index a91c5fa057..ef9e89ff1b 100644 --- a/src/app/community-list-page/community-list/community-list.component.spec.ts +++ b/src/app/community-list-page/community-list/community-list.component.spec.ts @@ -114,15 +114,9 @@ describe('CommunityListComponent', () => { beforeEach(async(() => { communityListServiceStub = { - topPageSize: 2, - topCurrentPage: 1, - collectionPageSize: 2, - subcommunityPageSize: 2, + pageSize: 2, expandedNodes: [], loadingNode: null, - getNextPageTopCommunities() { - this.topCurrentPage++; - }, getLoadingNodeFromStore() { return observableOf(this.loadingNode); }, @@ -133,12 +127,12 @@ describe('CommunityListComponent', () => { this.expandedNodes = expandedNodes; this.loadingNode = loadingNode; }, - loadCommunities(expandedNodes) { + loadCommunities(options, expandedNodes) { let flatnodes; let showMoreTopComNode = false; flatnodes = [...mockTopFlatnodesUnexpanded]; - const currentPage = this.topCurrentPage; - const elementsPerPage = this.topPageSize; + const currentPage = options.currentPage; + const elementsPerPage = this.pageSize; let endPageIndex = (currentPage * elementsPerPage); if (endPageIndex >= flatnodes.length) { endPageIndex = flatnodes.length; @@ -171,14 +165,14 @@ describe('CommunityListComponent', () => { collFlatnodes = [...collFlatnodes, toFlatNode(coll, observableOf(false), topNode.level + 1, false, topNode)]; }); if (isNotEmpty(subComFlatnodes)) { - const endSubComIndex = this.subcommunityPageSize * expandedParent.currentCommunityPage; + const endSubComIndex = this.pageSize * expandedParent.currentCommunityPage; flatnodes = [...flatnodes, ...subComFlatnodes.slice(0, endSubComIndex)]; if (subComFlatnodes.length > endSubComIndex) { flatnodes = [...flatnodes, showMoreFlatNode('community', topNode.level + 1, expandedParent)]; } } if (isNotEmpty(collFlatnodes)) { - const endColIndex = this.collectionPageSize * expandedParent.currentCollectionPage; + const endColIndex = this.pageSize * expandedParent.currentCollectionPage; flatnodes = [...flatnodes, ...collFlatnodes.slice(0, endColIndex)]; if (collFlatnodes.length > endColIndex) { flatnodes = [...flatnodes, showMoreFlatNode('collection', topNode.level + 1, expandedParent)]; diff --git a/src/app/community-list-page/community-list/community-list.component.ts b/src/app/community-list-page/community-list/community-list.component.ts index ddcd49cd1c..be96ff1a0a 100644 --- a/src/app/community-list-page/community-list/community-list.component.ts +++ b/src/app/community-list-page/community-list/community-list.component.ts @@ -1,5 +1,7 @@ -import { Component, OnDestroy, OnInit } from '@angular/core'; +import { Component, NgZone, OnDestroy, OnInit } from '@angular/core'; import { take } from 'rxjs/operators'; +import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model'; +import { FindListOptions } from '../../core/data/request.models'; import { CommunityListService, FlatNode } from '../community-list-service'; import { CommunityListDatasource } from '../community-list-datasource'; import { FlatTreeControl } from '@angular/cdk/tree'; @@ -27,17 +29,24 @@ export class CommunityListComponent implements OnInit, OnDestroy { dataSource: CommunityListDatasource; - constructor(private communityListService: CommunityListService) { + paginationConfig: FindListOptions; + + constructor(private communityListService: CommunityListService, + private zone: NgZone) { + this.paginationConfig = new FindListOptions(); + this.paginationConfig.elementsPerPage = 2; + this.paginationConfig.currentPage = 1; + this.paginationConfig.sort = new SortOptions('dc.title', SortDirection.ASC); } ngOnInit() { - this.dataSource = new CommunityListDatasource(this.communityListService); + this.dataSource = new CommunityListDatasource(this.communityListService, this.zone); this.communityListService.getLoadingNodeFromStore().pipe(take(1)).subscribe((result) => { this.loadingNode = result; }); this.communityListService.getExpandedNodesFromStore().pipe(take(1)).subscribe((result) => { this.expandedNodes = [...result]; - this.dataSource.loadCommunities(this.expandedNodes); + this.dataSource.loadCommunities(this.paginationConfig, this.expandedNodes); }); } @@ -74,7 +83,7 @@ export class CommunityListComponent implements OnInit, OnDestroy { node.currentCommunityPage = 1; } } - this.dataSource.loadCommunities(this.expandedNodes); + this.dataSource.loadCommunities(this.paginationConfig, this.expandedNodes); } /** @@ -94,10 +103,10 @@ export class CommunityListComponent implements OnInit, OnDestroy { const parentNodeInExpandedNodes = this.expandedNodes.find((node2: FlatNode) => node.parent.id === node2.id); parentNodeInExpandedNodes.currentCommunityPage++; } - this.dataSource.loadCommunities(this.expandedNodes); + this.dataSource.loadCommunities(this.paginationConfig, this.expandedNodes); } else { - this.communityListService.getNextPageTopCommunities(); - this.dataSource.loadCommunities(this.expandedNodes); + this.paginationConfig.currentPage++; + this.dataSource.loadCommunities(this.paginationConfig, this.expandedNodes); } } diff --git a/src/app/core/auth/auth-request.service.ts b/src/app/core/auth/auth-request.service.ts index 465fb69dd2..93f55389f9 100644 --- a/src/app/core/auth/auth-request.service.ts +++ b/src/app/core/auth/auth-request.service.ts @@ -1,12 +1,18 @@ import { Observable, of as observableOf, throwError as observableThrowError } from 'rxjs'; -import { distinctUntilChanged, filter, map, mergeMap, tap } from 'rxjs/operators'; -import { Inject, Injectable } from '@angular/core'; +import { distinctUntilChanged, filter, map, mergeMap, switchMap, tap } from 'rxjs/operators'; +import { Injectable } from '@angular/core'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { RequestService } from '../data/request.service'; -import { GlobalConfig } from '../../../config/global-config.interface'; import { isNotEmpty } from '../../shared/empty.util'; -import { AuthGetRequest, AuthPostRequest, GetRequest, PostRequest, RestRequest } from '../data/request.models'; -import { AuthStatusResponse, ErrorResponse } from '../cache/response.models'; +import { + AuthGetRequest, + AuthPostRequest, + GetRequest, + PostRequest, + RestRequest, + TokenPostRequest +} from '../data/request.models'; +import { AuthStatusResponse, ErrorResponse, TokenResponse } 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'; @@ -15,6 +21,7 @@ import { HttpClient } from '@angular/common/http'; export class AuthRequestService { protected linkName = 'authn'; protected browseEndpoint = ''; + protected shortlivedtokensEndpoint = 'shortlivedtokens'; constructor(protected halService: HALEndpointService, protected requestService: RequestService, @@ -67,4 +74,19 @@ export class AuthRequestService { mergeMap((request: GetRequest) => this.fetchRequest(request)), distinctUntilChanged()); } + + /** + * Send a POST request to retrieve a short-lived token which provides download access of restricted files + */ + public getShortlivedToken(): Observable { + return this.halService.getEndpoint(`${this.linkName}/${this.shortlivedtokensEndpoint}`).pipe( + filter((href: string) => isNotEmpty(href)), + distinctUntilChanged(), + map((endpointURL: string) => new TokenPostRequest(this.requestService.generateRequestId(), endpointURL)), + tap((request: PostRequest) => this.requestService.configure(request)), + switchMap((request: PostRequest) => this.requestService.getByUUID(request.uuid)), + getResponseFromEntry(), + map((response: TokenResponse) => response.token) + ); + } } diff --git a/src/app/core/auth/auth.actions.ts b/src/app/core/auth/auth.actions.ts index 9237c30db9..be4bdf2a26 100644 --- a/src/app/core/auth/auth.actions.ts +++ b/src/app/core/auth/auth.actions.ts @@ -3,7 +3,6 @@ import { Action } from '@ngrx/store'; // import type function 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'; @@ -31,9 +30,6 @@ export const AuthActionTypes = { LOG_OUT: type('dspace/auth/LOG_OUT'), LOG_OUT_ERROR: type('dspace/auth/LOG_OUT_ERROR'), LOG_OUT_SUCCESS: type('dspace/auth/LOG_OUT_SUCCESS'), - REGISTRATION: type('dspace/auth/REGISTRATION'), - REGISTRATION_ERROR: type('dspace/auth/REGISTRATION_ERROR'), - REGISTRATION_SUCCESS: type('dspace/auth/REGISTRATION_SUCCESS'), SET_REDIRECT_URL: type('dspace/auth/SET_REDIRECT_URL'), RETRIEVE_AUTHENTICATED_EPERSON: type('dspace/auth/RETRIEVE_AUTHENTICATED_EPERSON'), RETRIEVE_AUTHENTICATED_EPERSON_SUCCESS: type('dspace/auth/RETRIEVE_AUTHENTICATED_EPERSON_SUCCESS'), @@ -263,48 +259,6 @@ export class RetrieveTokenAction implements Action { public type: string = AuthActionTypes.RETRIEVE_TOKEN; } -/** - * Sign up. - * @class RegistrationAction - * @implements {Action} - */ -export class RegistrationAction implements Action { - public type: string = AuthActionTypes.REGISTRATION; - payload: EPerson; - - constructor(user: EPerson) { - this.payload = user; - } -} - -/** - * Sign up error. - * @class RegistrationErrorAction - * @implements {Action} - */ -export class RegistrationErrorAction implements Action { - public type: string = AuthActionTypes.REGISTRATION_ERROR; - payload: Error; - - constructor(payload: Error) { - this.payload = payload; - } -} - -/** - * Sign up success. - * @class RegistrationSuccessAction - * @implements {Action} - */ -export class RegistrationSuccessAction implements Action { - public type: string = AuthActionTypes.REGISTRATION_SUCCESS; - payload: EPerson; - - constructor(user: EPerson) { - this.payload = user; - } -} - /** * Add uthentication message. * @class AddAuthenticationMessageAction @@ -439,9 +393,6 @@ export type AuthActions | CheckAuthenticationTokenCookieAction | RedirectWhenAuthenticationIsRequiredAction | RedirectWhenTokenExpiredAction - | RegistrationAction - | RegistrationErrorAction - | RegistrationSuccessAction | AddAuthenticationMessageAction | RefreshTokenAction | RefreshTokenErrorAction diff --git a/src/app/core/auth/auth.effects.ts b/src/app/core/auth/auth.effects.ts index 5591ffbe39..37ef3b79bc 100644 --- a/src/app/core/auth/auth.effects.ts +++ b/src/app/core/auth/auth.effects.ts @@ -1,7 +1,7 @@ import { Injectable } from '@angular/core'; import { combineLatest as observableCombineLatest, Observable, of as observableOf } from 'rxjs'; -import { catchError, debounceTime, filter, map, switchMap, take, tap } from 'rxjs/operators'; +import { catchError, filter, map, switchMap, take, tap } from 'rxjs/operators'; // import @ngrx import { Actions, Effect, ofType } from '@ngrx/effects'; import { Action, select, Store } from '@ngrx/store'; @@ -30,9 +30,6 @@ import { RefreshTokenAction, RefreshTokenErrorAction, RefreshTokenSuccessAction, - RegistrationAction, - RegistrationErrorAction, - RegistrationSuccessAction, RetrieveAuthenticatedEpersonAction, RetrieveAuthenticatedEpersonErrorAction, RetrieveAuthenticatedEpersonSuccessAction, @@ -136,18 +133,6 @@ export class AuthEffects { }) ); - @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))) - ); - }) - ); - @Effect() public retrieveToken$: Observable = this.actions$.pipe( ofType(AuthActionTypes.RETRIEVE_TOKEN), diff --git a/src/app/core/auth/auth.reducer.ts b/src/app/core/auth/auth.reducer.ts index 16990b35a8..34c8fe2b41 100644 --- a/src/app/core/auth/auth.reducer.ts +++ b/src/app/core/auth/auth.reducer.ts @@ -115,7 +115,6 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut }); case AuthActionTypes.AUTHENTICATE_ERROR: - case AuthActionTypes.REGISTRATION_ERROR: return Object.assign({}, state, { authenticated: false, authToken: undefined, @@ -157,18 +156,6 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut userId: undefined }); - case AuthActionTypes.REGISTRATION: - return Object.assign({}, state, { - authenticated: false, - authToken: undefined, - error: undefined, - loading: true, - info: undefined - }); - - case AuthActionTypes.REGISTRATION_SUCCESS: - return state; - case AuthActionTypes.REFRESH_TOKEN: return Object.assign({}, state, { refreshing: true, diff --git a/src/app/core/auth/auth.service.spec.ts b/src/app/core/auth/auth.service.spec.ts index 3b6fae4dd1..a15d604cc4 100644 --- a/src/app/core/auth/auth.service.spec.ts +++ b/src/app/core/auth/auth.service.spec.ts @@ -1,17 +1,14 @@ import { async, inject, TestBed } from '@angular/core/testing'; import { CommonModule } from '@angular/common'; import { ActivatedRoute, Router } from '@angular/router'; - import { Store, StoreModule } from '@ngrx/store'; import { REQUEST } from '@nguniversal/express-engine/tokens'; import { of as observableOf } from 'rxjs'; - import { authReducer, AuthState } from './auth.reducer'; import { NativeWindowRef, NativeWindowService } from '../services/window.service'; import { AuthService, IMPERSONATING_COOKIE } from './auth.service'; import { RouterStub } from '../../shared/testing/router.stub'; import { ActivatedRouteStub } from '../../shared/testing/active-router.stub'; - import { CookieService } from '../services/cookie.service'; import { AuthRequestServiceStub } from '../../shared/testing/auth-request-service.stub'; import { AuthRequestService } from './auth-request.service'; @@ -49,6 +46,7 @@ describe('AuthService test', () => { let storage: CookieService; let token: AuthTokenInfo; let authenticatedState; + let unAuthenticatedState; let linkService; function init() { @@ -67,6 +65,13 @@ describe('AuthService test', () => { authToken: token, user: EPersonMock }; + unAuthenticatedState = { + authenticated: false, + loaded: true, + loading: false, + authToken: undefined, + user: undefined + }; authRequest = new AuthRequestServiceStub(); routeStub = new ActivatedRouteStub(); linkService = { @@ -214,6 +219,12 @@ describe('AuthService test', () => { }); }); + it('should return the shortlived token when user is logged in', () => { + authService.getShortlivedToken().subscribe((shortlivedToken: string) => { + expect(shortlivedToken).toEqual(authRequest.mockShortLivedToken); + }); + }); + it('should return token object when it is valid', () => { authService.hasValidAuthenticationToken().subscribe((tokenState: AuthTokenInfo) => { expect(tokenState).toBe(token); @@ -448,4 +459,44 @@ describe('AuthService test', () => { }); }); }); + + describe('when user is not logged in', () => { + beforeEach(async(() => { + init(); + TestBed.configureTestingModule({ + imports: [ + StoreModule.forRoot({ authReducer }, { + runtimeChecks: { + strictStateImmutability: false, + strictActionImmutability: false + } + }) + ], + providers: [ + { provide: AuthRequestService, useValue: authRequest }, + { provide: REQUEST, useValue: {} }, + { provide: Router, useValue: routerStub }, + { provide: RouteService, useValue: routeServiceStub }, + { provide: RemoteDataBuildService, useValue: linkService }, + CookieService, + AuthService + ] + }).compileComponents(); + })); + + beforeEach(inject([CookieService, AuthRequestService, Store, Router, RouteService], (cookieService: CookieService, authReqService: AuthRequestService, store: Store, router: Router, routeService: RouteService) => { + store + .subscribe((state) => { + (state as any).core = Object.create({}); + (state as any).core.auth = unAuthenticatedState; + }); + authService = new AuthService({}, window, undefined, authReqService, mockEpersonDataService, router, routeService, cookieService, store); + })); + + it('should return null for the shortlived token', () => { + authService.getShortlivedToken().subscribe((shortlivedToken: string) => { + expect(shortlivedToken).toBeNull(); + }); + }); + }); }); diff --git a/src/app/core/auth/auth.service.ts b/src/app/core/auth/auth.service.ts index 588d9e2675..fe9828bc73 100644 --- a/src/app/core/auth/auth.service.ts +++ b/src/app/core/auth/auth.service.ts @@ -270,18 +270,6 @@ export class AuthService { return observableOf(authMethods); } - /** - * Create a new user - * @returns {User} - */ - public create(user: EPerson): Observable { - // Normally you would do an HTTP request to POST the user - // details and then return the new user object - // but, let's just return the new user for this example. - // this._authenticated = true; - return observableOf(user); - } - /** * End session * @returns {Observable} @@ -546,4 +534,14 @@ export class AuthService { return this.getImpersonateID() === epersonId; } + /** + * Get a short-lived token for appending to download urls of restricted files + * Returns null if the user isn't authenticated + */ + getShortlivedToken(): Observable { + return this.isAuthenticated().pipe( + switchMap((authenticated) => authenticated ? this.authRequestService.getShortlivedToken() : observableOf(null)) + ); + } + } diff --git a/src/app/core/auth/token-response-parsing.service.spec.ts b/src/app/core/auth/token-response-parsing.service.spec.ts new file mode 100644 index 0000000000..35927708f6 --- /dev/null +++ b/src/app/core/auth/token-response-parsing.service.spec.ts @@ -0,0 +1,45 @@ +import { TokenResponseParsingService } from './token-response-parsing.service'; +import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; +import { TokenResponse } from '../cache/response.models'; + +describe('TokenResponseParsingService', () => { + let service: TokenResponseParsingService; + + beforeEach(() => { + service = new TokenResponseParsingService(); + }); + + describe('parse', () => { + it('should return a TokenResponse containing the token', () => { + const data = { + payload: { + token: 'valid-token' + }, + statusCode: 200, + statusText: 'OK' + } as DSpaceRESTV2Response; + const expected = new TokenResponse(data.payload.token, true, 200, 'OK'); + expect(service.parse(undefined, data)).toEqual(expected); + }); + + it('should return an empty TokenResponse when payload doesn\'t contain a token', () => { + const data = { + payload: {}, + statusCode: 200, + statusText: 'OK' + } as DSpaceRESTV2Response; + const expected = new TokenResponse(null, false, 200, 'OK'); + expect(service.parse(undefined, data)).toEqual(expected); + }); + + it('should return an error TokenResponse when the response failed', () => { + const data = { + payload: {}, + statusCode: 400, + statusText: 'BAD REQUEST' + } as DSpaceRESTV2Response; + const expected = new TokenResponse(null, false, 400, 'BAD REQUEST'); + expect(service.parse(undefined, data)).toEqual(expected); + }); + }); +}); diff --git a/src/app/core/auth/token-response-parsing.service.ts b/src/app/core/auth/token-response-parsing.service.ts new file mode 100644 index 0000000000..a1b1e23aa4 --- /dev/null +++ b/src/app/core/auth/token-response-parsing.service.ts @@ -0,0 +1,23 @@ +import { ResponseParsingService } from '../data/parsing.service'; +import { RestRequest } from '../data/request.models'; +import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; +import { RestResponse, TokenResponse } from '../cache/response.models'; +import { isNotEmpty } from '../../shared/empty.util'; +import { Injectable } from '@angular/core'; + +@Injectable() +/** + * A ResponseParsingService used to parse DSpaceRESTV2Response coming from the REST API to a token string + * wrapped in a TokenResponse + */ +export class TokenResponseParsingService implements ResponseParsingService { + + parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse { + if (isNotEmpty(data.payload) && isNotEmpty(data.payload.token) && (data.statusCode === 200)) { + return new TokenResponse(data.payload.token, true, data.statusCode, data.statusText); + } else { + return new TokenResponse(null, false, data.statusCode, data.statusText) + } + } + +} diff --git a/src/app/core/cache/builders/remote-data-build.service.ts b/src/app/core/cache/builders/remote-data-build.service.ts index 88d1890de2..6c9f40888f 100644 --- a/src/app/core/cache/builders/remote-data-build.service.ts +++ b/src/app/core/cache/builders/remote-data-build.service.ts @@ -150,12 +150,7 @@ export class RemoteDataBuildService { filterSuccessfulResponses(), map((response: DSOSuccessResponse) => { if (hasValue((response as DSOSuccessResponse).pageInfo)) { - const resPageInfo = (response as DSOSuccessResponse).pageInfo; - if (isNotEmpty(resPageInfo) && resPageInfo.currentPage >= 0) { - return Object.assign({}, resPageInfo, { currentPage: resPageInfo.currentPage + 1 }); - } else { - return resPageInfo; - } + return (response as DSOSuccessResponse).pageInfo; } }) ); diff --git a/src/app/core/cache/response.models.ts b/src/app/core/cache/response.models.ts index b40965dd0a..5f19185d1c 100644 --- a/src/app/core/cache/response.models.ts +++ b/src/app/core/cache/response.models.ts @@ -12,6 +12,7 @@ import { DSpaceObject } from '../shared/dspace-object.model'; import { MetadataSchema } from '../metadata/metadata-schema.model'; import { MetadataField } from '../metadata/metadata-field.model'; import { ContentSource } from '../shared/content-source.model'; +import { Registration } from '../shared/registration.model'; /* tslint:disable:max-classes-per-file */ export class RestResponse { @@ -166,6 +167,20 @@ export class AuthStatusResponse extends RestResponse { } } +/** + * A REST Response containing a token + */ +export class TokenResponse extends RestResponse { + constructor( + public token: string, + public isSuccessful: boolean, + public statusCode: number, + public statusText: string + ) { + super(isSuccessful, statusCode, statusText); + } +} + export class IntegrationSuccessResponse extends RestResponse { constructor( public dataDefinition: PaginatedList, @@ -257,4 +272,17 @@ export class ContentSourceSuccessResponse extends RestResponse { super(true, statusCode, statusText); } } + +/** + * A successful response containing a Registration + */ +export class RegistrationSuccessResponse extends RestResponse { + constructor( + public registration: Registration, + public statusCode: number, + public statusText: string, + ) { + super(true, statusCode, statusText); + } +} /* tslint:enable:max-classes-per-file */ diff --git a/src/app/core/core.module.ts b/src/app/core/core.module.ts index 37b3bb51e2..1fd44224a9 100644 --- a/src/app/core/core.module.ts +++ b/src/app/core/core.module.ts @@ -140,8 +140,10 @@ import { Version } from './shared/version.model'; import { VersionHistory } from './shared/version-history.model'; import { WorkflowActionDataService } from './data/workflow-action-data.service'; import { WorkflowAction } from './tasks/models/workflow-action-object.model'; +import { Registration } from './shared/registration.model'; import { MetadataSchemaDataService } from './data/metadata-schema-data.service'; import { MetadataFieldDataService } from './data/metadata-field-data.service'; +import { TokenResponseParsingService } from './auth/token-response-parsing.service'; /** * When not in production, endpoint responses can be mocked for testing purposes @@ -258,6 +260,7 @@ const PROVIDERS = [ WorkflowActionDataService, MetadataSchemaDataService, MetadataFieldDataService, + TokenResponseParsingService, // register AuthInterceptor as HttpInterceptor { provide: HTTP_INTERCEPTORS, @@ -308,7 +311,8 @@ export const models = ExternalSourceEntry, Version, VersionHistory, - WorkflowAction + WorkflowAction, + Registration ]; @NgModule({ diff --git a/src/app/core/data/eperson-registration.service.spec.ts b/src/app/core/data/eperson-registration.service.spec.ts new file mode 100644 index 0000000000..4c91ffd4f1 --- /dev/null +++ b/src/app/core/data/eperson-registration.service.spec.ts @@ -0,0 +1,89 @@ +import { RequestService } from './request.service'; +import { EpersonRegistrationService } from './eperson-registration.service'; +import { RegistrationSuccessResponse, RestResponse } from '../cache/response.models'; +import { RequestEntry } from './request.reducer'; +import { cold } from 'jasmine-marbles'; +import { PostRequest } from './request.models'; +import { Registration } from '../shared/registration.model'; +import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub'; + +describe('EpersonRegistrationService', () => { + let service: EpersonRegistrationService; + let requestService: RequestService; + + let halService: any; + + const registration = new Registration(); + registration.email = 'test@mail.org'; + + const registrationWithUser = new Registration(); + registrationWithUser.email = 'test@mail.org'; + registrationWithUser.user = 'test-uuid'; + + beforeEach(() => { + halService = new HALEndpointServiceStub('rest-url'); + + requestService = jasmine.createSpyObj('requestService', { + generateRequestId: 'request-id', + configure: {}, + getByUUID: cold('a', + {a: Object.assign(new RequestEntry(), {response: new RestResponse(true, 200, 'Success')})}) + }); + service = new EpersonRegistrationService( + requestService, + halService + ); + }); + + describe('getRegistrationEndpoint', () => { + it('should retrieve the registration endpoint', () => { + const expected = service.getRegistrationEndpoint(); + + expected.subscribe(((value) => { + expect(value).toEqual('rest-url/registrations'); + })); + }); + }); + + describe('getTokenSearchEndpoint', () => { + it('should return the token search endpoint for a specified token', () => { + const expected = service.getTokenSearchEndpoint('test-token'); + + expected.subscribe(((value) => { + expect(value).toEqual('rest-url/registrations/search/findByToken?token=test-token'); + })); + }); + }); + + describe('registerEmail', () => { + it('should send an email registration', () => { + + const expected = service.registerEmail('test@mail.org'); + + expect(requestService.configure).toHaveBeenCalledWith(new PostRequest('request-id', 'rest-url/registrations', registration)); + expect(expected).toBeObservable(cold('a', {a: new RestResponse(true, 200, 'Success')})); + }); + }); + + describe('searchByToken', () => { + beforeEach(() => { + (requestService.getByUUID as jasmine.Spy).and.returnValue( + cold('a', + {a: Object.assign(new RequestEntry(), {response: new RegistrationSuccessResponse(registrationWithUser, 200, 'Success')})}) + ); + }); + it('should return a registration corresponding to the provided token', () => { + const expected = service.searchByToken('test-token'); + + expect(expected).toBeObservable(cold('(a|)', { + a: Object.assign(new Registration(), { + email: registrationWithUser.email, + token: 'test-token', + user: registrationWithUser.user + }) + })); + + }); + }); + +}); diff --git a/src/app/core/data/eperson-registration.service.ts b/src/app/core/data/eperson-registration.service.ts new file mode 100644 index 0000000000..caa6150711 --- /dev/null +++ b/src/app/core/data/eperson-registration.service.ts @@ -0,0 +1,108 @@ +import { Injectable } from '@angular/core'; +import { RequestService } from './request.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { GetRequest, PostRequest } from './request.models'; +import { Observable } from 'rxjs'; +import { filter, find, map, take } from 'rxjs/operators'; +import { hasValue, isNotEmpty } from '../../shared/empty.util'; +import { Registration } from '../shared/registration.model'; +import { filterSuccessfulResponses, getResponseFromEntry } from '../shared/operators'; +import { ResponseParsingService } from './parsing.service'; +import { GenericConstructor } from '../shared/generic-constructor'; +import { RegistrationResponseParsingService } from './registration-response-parsing.service'; +import { RegistrationSuccessResponse } from '../cache/response.models'; + +@Injectable( + { + providedIn: 'root', + } +) +/** + * Service that will register a new email address and request a token + */ +export class EpersonRegistrationService { + + protected linkPath = 'registrations'; + protected searchByTokenPath = '/search/findByToken?token='; + + constructor( + protected requestService: RequestService, + protected halService: HALEndpointService, + ) { + + } + + /** + * Retrieves the Registration endpoint + */ + getRegistrationEndpoint(): Observable { + return this.halService.getEndpoint(this.linkPath); + } + + /** + * Retrieves the endpoint to search by registration token + */ + getTokenSearchEndpoint(token: string): Observable { + return this.halService.getEndpoint(this.linkPath).pipe( + filter((href: string) => isNotEmpty(href)), + map((href: string) => `${href}${this.searchByTokenPath}${token}`)); + } + + /** + * Register a new email address + * @param email + */ + registerEmail(email: string) { + const registration = new Registration(); + registration.email = email; + + const requestId = this.requestService.generateRequestId(); + + const hrefObs = this.getRegistrationEndpoint(); + + hrefObs.pipe( + find((href: string) => hasValue(href)), + map((href: string) => { + const request = new PostRequest(requestId, href, registration); + this.requestService.configure(request); + }) + ).subscribe(); + + return this.requestService.getByUUID(requestId).pipe( + getResponseFromEntry() + ); + } + + /** + * Search a registration based on the provided token + * @param token + */ + searchByToken(token: string): Observable { + const requestId = this.requestService.generateRequestId(); + + const hrefObs = this.getTokenSearchEndpoint(token); + + hrefObs.pipe( + find((href: string) => hasValue(href)), + map((href: string) => { + const request = new GetRequest(requestId, href); + Object.assign(request, { + getResponseParser(): GenericConstructor { + return RegistrationResponseParsingService; + } + }); + this.requestService.configure(request); + }) + ).subscribe(); + + return this.requestService.getByUUID(requestId).pipe( + filterSuccessfulResponses(), + map((restResponse: RegistrationSuccessResponse) => { + return Object.assign(new Registration(), {email: restResponse.registration.email, token: token, user: restResponse.registration.user}); + }), + take(1), + ); + + } + +} diff --git a/src/app/core/data/registration-response-parsing.service.spec.ts b/src/app/core/data/registration-response-parsing.service.spec.ts new file mode 100644 index 0000000000..52044798a3 --- /dev/null +++ b/src/app/core/data/registration-response-parsing.service.spec.ts @@ -0,0 +1,22 @@ +import { Registration } from '../shared/registration.model'; +import { RegistrationResponseParsingService } from './registration-response-parsing.service'; +import { RegistrationSuccessResponse } from '../cache/response.models'; + +describe('RegistrationResponseParsingService', () => { + describe('parse', () => { + const registration = Object.assign(new Registration(), {email: 'test@email.org', token: 'test-token'}); + const registrationResponseParsingService = new RegistrationResponseParsingService(); + + const data = { + payload: {email: 'test@email.org', token: 'test-token'}, + statusCode: 200, + statusText: 'Success' + }; + + it('should parse a registration response', () => { + const expected = registrationResponseParsingService.parse({} as any, data); + + expect(expected).toEqual(new RegistrationSuccessResponse(registration, 200, 'Success')); + }); + }); +}); diff --git a/src/app/core/data/registration-response-parsing.service.ts b/src/app/core/data/registration-response-parsing.service.ts new file mode 100644 index 0000000000..8f5c35a3e1 --- /dev/null +++ b/src/app/core/data/registration-response-parsing.service.ts @@ -0,0 +1,24 @@ +import { Injectable } from '@angular/core'; +import { RegistrationSuccessResponse, RestResponse } from '../cache/response.models'; +import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; +import { ResponseParsingService } from './parsing.service'; +import { RestRequest } from './request.models'; +import { Registration } from '../shared/registration.model'; + +@Injectable({ + providedIn: 'root', +}) +/** + * Parsing service responsible for parsing a Registration response + */ +export class RegistrationResponseParsingService implements ResponseParsingService { + + parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse { + const payload = data.payload; + + const registration = Object.assign(new Registration(), payload); + + return new RegistrationSuccessResponse(registration, data.statusCode, data.statusText); + } + +} diff --git a/src/app/core/data/request.models.ts b/src/app/core/data/request.models.ts index b484a2ba4e..2438ea02b2 100644 --- a/src/app/core/data/request.models.ts +++ b/src/app/core/data/request.models.ts @@ -18,6 +18,7 @@ import { URLCombiner } from '../url-combiner/url-combiner'; import { TaskResponseParsingService } from '../tasks/task-response-parsing.service'; import { ContentSourceResponseParsingService } from './content-source-response-parsing.service'; import { MappedCollectionsReponseParsingService } from './mapped-collections-reponse-parsing.service'; +import { TokenResponseParsingService } from '../auth/token-response-parsing.service'; /* tslint:disable:max-classes-per-file */ @@ -239,6 +240,15 @@ export class AuthGetRequest extends GetRequest { } } +/** + * A POST request for retrieving a token + */ +export class TokenPostRequest extends PostRequest { + getResponseParser(): GenericConstructor { + return TokenResponseParsingService; + } +} + export class IntegrationRequest extends GetRequest { constructor(uuid: string, href: string) { super(uuid, href); diff --git a/src/app/core/eperson/eperson-data.service.spec.ts b/src/app/core/eperson/eperson-data.service.spec.ts index 0cb56f14a2..a1a6951545 100644 --- a/src/app/core/eperson/eperson-data.service.spec.ts +++ b/src/app/core/eperson/eperson-data.service.spec.ts @@ -14,7 +14,7 @@ import { CoreState } from '../core.reducers'; import { ChangeAnalyzer } from '../data/change-analyzer'; import { PaginatedList } from '../data/paginated-list'; import { RemoteData } from '../data/remote-data'; -import { DeleteByIDRequest, FindListOptions, PatchRequest } from '../data/request.models'; +import { DeleteByIDRequest, FindListOptions, PatchRequest, PostRequest } from '../data/request.models'; import { RequestEntry } from '../data/request.reducer'; import { RequestService } from '../data/request.service'; import { HALEndpointService } from '../shared/hal-endpoint.service'; @@ -291,6 +291,25 @@ describe('EPersonDataService', () => { }); }); + describe('createEPersonForToken', () => { + it('should sent a postRquest with an eperson to the token endpoint', () => { + service.createEPersonForToken(EPersonMock, 'test-token'); + + const expected = new PostRequest(requestService.generateRequestId(), epersonsEndpoint + '?token=test-token', EPersonMock); + expect(requestService.configure).toHaveBeenCalledWith(expected); + }); + }); + describe('patchPasswordWithToken', () => { + it('should sent a patch request with an uuid, token and new password to the epersons endpoint', () => { + service.patchPasswordWithToken('test-uuid', 'test-token','test-password'); + + const operation = Object.assign({ op: 'replace', path: '/password', value: 'test-password' }); + const expected = new PatchRequest(requestService.generateRequestId(), epersonsEndpoint + '/test-uuid?token=test-token', [operation]); + + expect(requestService.configure).toHaveBeenCalledWith(expected); + }); + }); + }); function getRemotedataObservable(obj: any): Observable> { diff --git a/src/app/core/eperson/eperson-data.service.ts b/src/app/core/eperson/eperson-data.service.ts index 86e53178a0..8723e2dc87 100644 --- a/src/app/core/eperson/eperson-data.service.ts +++ b/src/app/core/eperson/eperson-data.service.ts @@ -3,7 +3,7 @@ import { Injectable } from '@angular/core'; import { createSelector, select, Store } from '@ngrx/store'; import { Operation } from 'fast-json-patch/lib/core'; import { Observable } from 'rxjs'; -import { filter, map, take } from 'rxjs/operators'; +import { filter, find, map, take } from 'rxjs/operators'; import { EPeopleRegistryCancelEPersonAction, EPeopleRegistryEditEPersonAction @@ -22,12 +22,13 @@ import { DataService } from '../data/data.service'; import { DSOChangeAnalyzer } from '../data/dso-change-analyzer.service'; import { PaginatedList } from '../data/paginated-list'; import { RemoteData } from '../data/remote-data'; -import { FindListOptions, FindListRequest, PatchRequest, } from '../data/request.models'; +import { FindListOptions, FindListRequest, PatchRequest, PostRequest, } from '../data/request.models'; import { RequestService } from '../data/request.service'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { getRemoteDataPayload, getSucceededRemoteData } from '../shared/operators'; import { EPerson } from './models/eperson.model'; import { EPERSON } from './models/eperson.resource-type'; +import { RequestEntry } from '../data/request.reducer'; const ePeopleRegistryStateSelector = (state: AppState) => state.epeopleRegistry; const editEPersonSelector = createSelector(ePeopleRegistryStateSelector, (ePeopleRegistryState: EPeopleRegistryState) => ePeopleRegistryState.editEPerson); @@ -165,17 +166,17 @@ export class EPersonDataService extends DataService { if (hasValue(oldEPerson.email) && oldEPerson.email !== newEPerson.email) { operations = [...operations, { op: 'replace', path: '/email', value: newEPerson.email - }] + }]; } if (hasValue(oldEPerson.requireCertificate) && oldEPerson.requireCertificate !== newEPerson.requireCertificate) { operations = [...operations, { op: 'replace', path: '/certificate', value: newEPerson.requireCertificate - }] + }]; } if (hasValue(oldEPerson.canLogIn) && oldEPerson.canLogIn !== newEPerson.canLogIn) { operations = [...operations, { op: 'replace', path: '/canLogIn', value: newEPerson.canLogIn - }] + }]; } return operations; } @@ -200,7 +201,7 @@ export class EPersonDataService extends DataService { * Method to retrieve the eperson that is currently being edited */ public getActiveEPerson(): Observable { - return this.store.pipe(select(editEPersonSelector)) + return this.store.pipe(select(editEPersonSelector)); } /** @@ -249,4 +250,54 @@ export class EPersonDataService extends DataService { return '/admin/access-control/epeople'; } + /** + * Create a new EPerson using a token + * @param eperson + * @param token + */ + public createEPersonForToken(eperson: EPerson, token: string) { + const requestId = this.requestService.generateRequestId(); + const hrefObs = this.getBrowseEndpoint().pipe( + map((href: string) => `${href}?token=${token}`)); + hrefObs.pipe( + find((href: string) => hasValue(href)), + map((href: string) => { + const request = new PostRequest(requestId, href, eperson); + this.requestService.configure(request); + }) + ).subscribe(); + + return this.fetchResponse(requestId); + + } + + /** + * Sends a patch request to update an epersons password based on a forgot password token + * @param uuid Uuid of the eperson + * @param token The forgot password token + * @param password The new password value + */ + patchPasswordWithToken(uuid: string, token: string, password: string): Observable { + const requestId = this.requestService.generateRequestId(); + + const operation = Object.assign({ op: 'replace', path: '/password', value: password }); + + const hrefObs = this.halService.getEndpoint(this.linkPath).pipe( + map((endpoint: string) => this.getIDHref(endpoint, uuid)), + map((href: string) => `${href}?token=${token}`)); + + hrefObs.pipe( + find((href: string) => hasValue(href)), + map((href: string) => { + const request = new PatchRequest(requestId, href, [operation]); + this.requestService.configure(request); + }) + ).subscribe(); + + return this.requestService.getByUUID(requestId).pipe( + find((request: RequestEntry) => request.completed), + map((request: RequestEntry) => request.response) + ); + } + } diff --git a/src/app/core/eperson/models/eperson.model.ts b/src/app/core/eperson/models/eperson.model.ts index bb99022112..86202f1a55 100644 --- a/src/app/core/eperson/models/eperson.model.ts +++ b/src/app/core/eperson/models/eperson.model.ts @@ -57,6 +57,12 @@ export class EPerson extends DSpaceObject { @autoserialize public selfRegistered: boolean; + /** + * The password of this EPerson + */ + @autoserialize + public password: string; + /** * Getter to retrieve the EPerson's full name as a string */ diff --git a/src/app/core/shared/file.service.ts b/src/app/core/shared/file.service.ts index 7e89a4e5dd..ca0a409b2d 100644 --- a/src/app/core/shared/file.service.ts +++ b/src/app/core/shared/file.service.ts @@ -1,10 +1,10 @@ -import { Injectable } from '@angular/core'; -import { HttpHeaders } from '@angular/common/http'; - -import { DSpaceRESTv2Service, HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service'; -import { RestRequestMethod } from '../data/rest-request-method'; -import { saveAs } from 'file-saver'; +import { Inject, Injectable } from '@angular/core'; import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; +import { AuthService } from '../auth/auth.service'; +import { take } from 'rxjs/operators'; +import { NativeWindowRef, NativeWindowService } from '../services/window.service'; +import { URLCombiner } from '../url-combiner/url-combiner'; +import { hasValue } from '../../shared/empty.util'; /** * Provides utility methods to save files on the client-side. @@ -12,22 +12,20 @@ import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response. @Injectable() export class FileService { constructor( - private restService: DSpaceRESTv2Service + @Inject(NativeWindowService) protected _window: NativeWindowRef, + private authService: AuthService ) { } /** - * Makes a HTTP Get request to download a file + * Combines an URL with a short-lived token and sets the current URL to the newly created one * * @param url * file url */ downloadFile(url: string) { - const headers = new HttpHeaders(); - const options: HttpOptions = Object.create({headers, responseType: 'blob'}); - return this.restService.request(RestRequestMethod.GET, url, null, options) - .subscribe((data) => { - saveAs(data.payload as Blob, this.getFileNameFromResponseContentDisposition(data)); - }); + this.authService.getShortlivedToken().pipe(take(1)).subscribe((token) => { + this._window.nativeWindow.location.href = hasValue(token) ? new URLCombiner(url, `?authentication-token=${token}`).toString() : url; + }); } /** diff --git a/src/app/core/shared/registration.model.ts b/src/app/core/shared/registration.model.ts new file mode 100644 index 0000000000..1943be2db5 --- /dev/null +++ b/src/app/core/shared/registration.model.ts @@ -0,0 +1,26 @@ +/** + * Model representing a registration + */ +export class Registration { + + /** + * The object type + */ + type: string; + + /** + * The email linked to the registration + */ + email: string; + + /** + * The user linked to the registration + */ + user: string; + + /** + * The token linked to the registration + */ + token: string; + +} diff --git a/src/app/forgot-password/forgot-password-email/forgot-email.component.html b/src/app/forgot-password/forgot-password-email/forgot-email.component.html new file mode 100644 index 0000000000..263f142c2e --- /dev/null +++ b/src/app/forgot-password/forgot-password-email/forgot-email.component.html @@ -0,0 +1,3 @@ + + \ No newline at end of file diff --git a/src/app/forgot-password/forgot-password-email/forgot-email.component.spec.ts b/src/app/forgot-password/forgot-password-email/forgot-email.component.spec.ts new file mode 100644 index 0000000000..d466cbf11b --- /dev/null +++ b/src/app/forgot-password/forgot-password-email/forgot-email.component.spec.ts @@ -0,0 +1,29 @@ +import { ForgotEmailComponent } from './forgot-email.component'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { CommonModule } from '@angular/common'; +import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; +import { TranslateModule } from '@ngx-translate/core'; +import { ReactiveFormsModule } from '@angular/forms'; + +describe('ForgotEmailComponent', () => { + let comp: ForgotEmailComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [CommonModule, TranslateModule.forRoot(), ReactiveFormsModule], + declarations: [ForgotEmailComponent], + schemas: [CUSTOM_ELEMENTS_SCHEMA] + }).compileComponents(); + })); + beforeEach(() => { + fixture = TestBed.createComponent(ForgotEmailComponent); + comp = fixture.componentInstance; + + fixture.detectChanges(); + }); + + it('should be defined', () => { + expect(comp).toBeDefined(); + }); +}); diff --git a/src/app/forgot-password/forgot-password-email/forgot-email.component.ts b/src/app/forgot-password/forgot-password-email/forgot-email.component.ts new file mode 100644 index 0000000000..5e18aff113 --- /dev/null +++ b/src/app/forgot-password/forgot-password-email/forgot-email.component.ts @@ -0,0 +1,12 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'ds-forgot-email', + templateUrl: './forgot-email.component.html' +}) +/** + * Component responsible the forgot password email step + */ +export class ForgotEmailComponent { + +} diff --git a/src/app/forgot-password/forgot-password-form/forgot-password-form.component.html b/src/app/forgot-password/forgot-password-form/forgot-password-form.component.html new file mode 100644 index 0000000000..06a1909f00 --- /dev/null +++ b/src/app/forgot-password/forgot-password-form/forgot-password-form.component.html @@ -0,0 +1,36 @@ +
+

{{'forgot-password.form.head' | translate}}

+
+
{{'forgot-password.form.identification.header' | translate}}
+
+
+
+ + {{(registration$ |async).email}}
+
+
+
+ +
+
{{'forgot-password.form.card.security' | translate}}
+
+ + +
+
+ +
+
+ +
+
+
\ No newline at end of file diff --git a/src/app/forgot-password/forgot-password-form/forgot-password-form.component.spec.ts b/src/app/forgot-password/forgot-password-form/forgot-password-form.component.spec.ts new file mode 100644 index 0000000000..7c23a2af94 --- /dev/null +++ b/src/app/forgot-password/forgot-password-form/forgot-password-form.component.spec.ts @@ -0,0 +1,117 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { of as observableOf } from 'rxjs'; +import { RouterStub } from '../../shared/testing/router.stub'; +import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub'; +import { RestResponse } from '../../core/cache/response.models'; +import { CommonModule } from '@angular/common'; +import { RouterTestingModule } from '@angular/router/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { FormBuilder, ReactiveFormsModule } from '@angular/forms'; +import { ActivatedRoute, Router } from '@angular/router'; +import { Store } from '@ngrx/store'; +import { EPersonDataService } from '../../core/eperson/eperson-data.service'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; +import { CoreState } from '../../core/core.reducers'; +import { Registration } from '../../core/shared/registration.model'; +import { ForgotPasswordFormComponent } from './forgot-password-form.component'; +import { By } from '@angular/platform-browser'; +import { AuthenticateAction } from '../../core/auth/auth.actions'; + +describe('ForgotPasswordFormComponent', () => { + let comp: ForgotPasswordFormComponent; + let fixture: ComponentFixture; + + let router; + let route; + let ePersonDataService: EPersonDataService; + let notificationsService; + let store: Store; + + const registration = Object.assign(new Registration(), { + email: 'test@email.org', + user: 'test-uuid', + token: 'test-token' + }); + + beforeEach(async(() => { + + route = {data: observableOf({registration: registration})}; + router = new RouterStub(); + notificationsService = new NotificationsServiceStub(); + + ePersonDataService = jasmine.createSpyObj('ePersonDataService', { + patchPasswordWithToken: observableOf(new RestResponse(true, 200, 'Success')) + }); + + store = jasmine.createSpyObj('store', { + dispatch: {}, + }); + + TestBed.configureTestingModule({ + imports: [CommonModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), ReactiveFormsModule], + declarations: [ForgotPasswordFormComponent], + providers: [ + {provide: Router, useValue: router}, + {provide: ActivatedRoute, useValue: route}, + {provide: Store, useValue: store}, + {provide: EPersonDataService, useValue: ePersonDataService}, + {provide: FormBuilder, useValue: new FormBuilder()}, + {provide: NotificationsService, useValue: notificationsService}, + ], + schemas: [CUSTOM_ELEMENTS_SCHEMA] + }).compileComponents(); + })); + beforeEach(() => { + fixture = TestBed.createComponent(ForgotPasswordFormComponent); + comp = fixture.componentInstance; + + fixture.detectChanges(); + }); + + describe('init', () => { + it('should initialise mail address', () => { + const elem = fixture.debugElement.queryAll(By.css('span#email'))[0].nativeElement; + expect(elem.innerHTML).toContain('test@email.org'); + }); + }); + + describe('submit', () => { + + it('should submit a patch request for the user uuid and log in on success', () => { + comp.password = 'password'; + comp.isInValid = false; + + comp.submit(); + + expect(ePersonDataService.patchPasswordWithToken).toHaveBeenCalledWith('test-uuid', 'test-token', 'password'); + expect(store.dispatch).toHaveBeenCalledWith(new AuthenticateAction('test@email.org', 'password')); + expect(router.navigate).toHaveBeenCalledWith(['/home']); + expect(notificationsService.success).toHaveBeenCalled(); + }); + + it('should submit a patch request for the user uuid and stay on page on error', () => { + + (ePersonDataService.patchPasswordWithToken as jasmine.Spy).and.returnValue(observableOf(new RestResponse(false, 500, 'Error'))); + + comp.password = 'password'; + comp.isInValid = false; + + comp.submit(); + + expect(ePersonDataService.patchPasswordWithToken).toHaveBeenCalledWith('test-uuid', 'test-token', 'password'); + expect(store.dispatch).not.toHaveBeenCalled(); + expect(router.navigate).not.toHaveBeenCalled(); + expect(notificationsService.error).toHaveBeenCalled(); + }); + it('should submit a patch request for the user uuid when the form is invalid', () => { + + comp.password = 'password'; + comp.isInValid = true; + + comp.submit(); + + expect(ePersonDataService.patchPasswordWithToken).not.toHaveBeenCalled(); + }); + }) +}); diff --git a/src/app/forgot-password/forgot-password-form/forgot-password-form.component.ts b/src/app/forgot-password/forgot-password-form/forgot-password-form.component.ts new file mode 100644 index 0000000000..1ad66a0b04 --- /dev/null +++ b/src/app/forgot-password/forgot-password-form/forgot-password-form.component.ts @@ -0,0 +1,87 @@ +import { Component } from '@angular/core'; +import { EPersonDataService } from '../../core/eperson/eperson-data.service'; +import { ErrorResponse, RestResponse } from '../../core/cache/response.models'; +import { TranslateService } from '@ngx-translate/core'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { Observable } from 'rxjs'; +import { Registration } from '../../core/shared/registration.model'; +import { map } from 'rxjs/operators'; +import { ActivatedRoute, Router } from '@angular/router'; +import { AuthenticateAction } from '../../core/auth/auth.actions'; +import { Store } from '@ngrx/store'; +import { CoreState } from '../../core/core.reducers'; + +@Component({ + selector: 'ds-forgot-password-form', + templateUrl: './forgot-password-form.component.html' +}) +/** + * Component for a user to enter a new password for a forgot token. + */ +export class ForgotPasswordFormComponent { + + registration$: Observable; + + token: string; + email: string; + user: string; + + isInValid = true; + password: string; + + /** + * Prefix for the notification messages of this component + */ + NOTIFICATIONS_PREFIX = 'forgot-password.form.notification'; + + constructor(private ePersonDataService: EPersonDataService, + private translateService: TranslateService, + private notificationsService: NotificationsService, + private store: Store, + private router: Router, + private route: ActivatedRoute, + ) { + } + + ngOnInit(): void { + this.registration$ = this.route.data.pipe( + map((data) => data.registration as Registration), + ); + this.registration$.subscribe((registration: Registration) => { + this.email = registration.email; + this.token = registration.token; + this.user = registration.user; + }); + } + + setInValid($event: boolean) { + this.isInValid = $event; + } + + setPasswordValue($event: string) { + this.password = $event; + } + + /** + * Submits the password to the eperson service to be updated. + * The submission will not be made when the form is not valid. + */ + submit() { + if (!this.isInValid) { + this.ePersonDataService.patchPasswordWithToken(this.user, this.token, this.password).subscribe((response: RestResponse) => { + if (response.isSuccessful) { + this.notificationsService.success( + this.translateService.instant(this.NOTIFICATIONS_PREFIX + '.success.title'), + this.translateService.instant(this.NOTIFICATIONS_PREFIX + '.success.content') + ); + this.store.dispatch(new AuthenticateAction(this.email, this.password)); + this.router.navigate(['/home']); + } else { + this.notificationsService.error( + this.translateService.instant(this.NOTIFICATIONS_PREFIX + '.error.title'), (response as ErrorResponse).errorMessage + ); + } + }); + } + } +} diff --git a/src/app/forgot-password/forgot-password-routing.module.ts b/src/app/forgot-password/forgot-password-routing.module.ts new file mode 100644 index 0000000000..702de03a9d --- /dev/null +++ b/src/app/forgot-password/forgot-password-routing.module.ts @@ -0,0 +1,32 @@ +import { NgModule } from '@angular/core'; +import { RouterModule } from '@angular/router'; +import { ItemPageResolver } from '../+item-page/item-page.resolver'; +import { RegistrationResolver } from '../register-email-form/registration.resolver'; +import { ForgotPasswordFormComponent } from './forgot-password-form/forgot-password-form.component'; +import { ForgotEmailComponent } from './forgot-password-email/forgot-email.component'; + +@NgModule({ + imports: [ + RouterModule.forChild([ + { + path: '', + component: ForgotEmailComponent, + data: {title: 'forgot-password.title'}, + }, + { + path: ':token', + component: ForgotPasswordFormComponent, + resolve: {registration: RegistrationResolver} + } + ]) + ], + providers: [ + RegistrationResolver, + ItemPageResolver + ] +}) +/** + * This module defines the routing to the components related to the forgot password components. + */ +export class ForgotPasswordRoutingModule { +} diff --git a/src/app/forgot-password/forgot-password.module.ts b/src/app/forgot-password/forgot-password.module.ts new file mode 100644 index 0000000000..5f01f0fcd2 --- /dev/null +++ b/src/app/forgot-password/forgot-password.module.ts @@ -0,0 +1,31 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { SharedModule } from '../shared/shared.module'; +import { ForgotEmailComponent } from './forgot-password-email/forgot-email.component'; +import { ForgotPasswordRoutingModule } from './forgot-password-routing.module'; +import { RegisterEmailFormModule } from '../register-email-form/register-email-form.module'; +import { ForgotPasswordFormComponent } from './forgot-password-form/forgot-password-form.component'; +import { ProfilePageModule } from '../profile-page/profile-page.module'; + +@NgModule({ + imports: [ + CommonModule, + SharedModule, + ForgotPasswordRoutingModule, + RegisterEmailFormModule, + ProfilePageModule, + ], + declarations: [ + ForgotEmailComponent, + ForgotPasswordFormComponent + ], + providers: [], + entryComponents: [] +}) + +/** + * Module related to the Forgot Password components + */ +export class ForgotPasswordModule { + +} diff --git a/src/app/profile-page/profile-page-security-form/profile-page-security-form.component.html b/src/app/profile-page/profile-page-security-form/profile-page-security-form.component.html index 50a081c6f2..ad9f768297 100644 --- a/src/app/profile-page/profile-page-security-form/profile-page-security-form.component.html +++ b/src/app/profile-page/profile-page-security-form/profile-page-security-form.component.html @@ -1,9 +1,10 @@ -
{{'profile.security.form.info' | translate}}
+
{{FORM_PREFIX + 'info' | translate}}
-
{{'profile.security.form.error.password-length' | translate}}
-
{{'profile.security.form.error.matching-passwords' | translate}}
+
{{FORM_PREFIX + 'error.password-length' | translate}}
+
{{FORM_PREFIX + 'error.matching-passwords' | translate}}
+
{{FORM_PREFIX + 'error.empty-password' | translate}}
diff --git a/src/app/profile-page/profile-page-security-form/profile-page-security-form.component.spec.ts b/src/app/profile-page/profile-page-security-form/profile-page-security-form.component.spec.ts index 225bd8507e..ba487f7158 100644 --- a/src/app/profile-page/profile-page-security-form/profile-page-security-form.component.spec.ts +++ b/src/app/profile-page/profile-page-security-form/profile-page-security-form.component.spec.ts @@ -1,5 +1,4 @@ -import { async, ComponentFixture, TestBed } from '@angular/core/testing'; -import { EPerson } from '../../core/eperson/models/eperson.model'; +import { async, ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing'; import { NO_ERRORS_SCHEMA } from '@angular/core'; import { VarDirective } from '../../shared/utils/var.directive'; import { TranslateModule } from '@ngx-translate/core'; @@ -15,18 +14,10 @@ describe('ProfilePageSecurityFormComponent', () => { let component: ProfilePageSecurityFormComponent; let fixture: ComponentFixture; - let user; - let epersonService; let notificationsService; function init() { - user = Object.assign(new EPerson(), { - _links: { - self: { href: 'user-selflink' } - } - }); - epersonService = jasmine.createSpyObj('epersonService', { patch: observableOf(new RestResponse(true, 200, 'OK')) }); @@ -43,8 +34,8 @@ describe('ProfilePageSecurityFormComponent', () => { declarations: [ProfilePageSecurityFormComponent, VarDirective], imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([])], providers: [ - { provide: EPersonDataService, useValue: epersonService }, - { provide: NotificationsService, useValue: notificationsService }, + {provide: EPersonDataService, useValue: epersonService}, + {provide: NotificationsService, useValue: notificationsService}, FormBuilderService ], schemas: [NO_ERRORS_SCHEMA] @@ -54,65 +45,35 @@ describe('ProfilePageSecurityFormComponent', () => { beforeEach(() => { fixture = TestBed.createComponent(ProfilePageSecurityFormComponent); component = fixture.componentInstance; - component.user = user; fixture.detectChanges(); }); - describe('updateSecurity', () => { - describe('when no values changed', () => { - let result; - + describe('On value change', () => { + describe('when the password has changed', () => { beforeEach(() => { - result = component.updateSecurity(); + component.formGroup.patchValue({password: 'password'}); + component.formGroup.patchValue({passwordrepeat: 'password'}); }); - it('should return false', () => { - expect(result).toEqual(false); - }); + it('should emit the value and validity on password change with invalid validity', fakeAsync(() => { + spyOn(component.passwordValue, 'emit'); + spyOn(component.isInvalid, 'emit'); + component.formGroup.patchValue({password: 'new-password'}); - it('should not call epersonService.patch', () => { - expect(epersonService.patch).not.toHaveBeenCalled(); - }); - }); + tick(300); - describe('when password is filled in, but the confirm field is empty', () => { - let result; + expect(component.passwordValue.emit).toHaveBeenCalledWith('new-password'); + expect(component.isInvalid.emit).toHaveBeenCalledWith(true); + })); - beforeEach(() => { - setModelValue('password', 'test'); - result = component.updateSecurity(); - }); + it('should emit the value on password change', fakeAsync(() => { + spyOn(component.passwordValue, 'emit'); + component.formGroup.patchValue({password: 'new-password'}); - it('should return true', () => { - expect(result).toEqual(true); - }); - }); + tick(300); - describe('when both password fields are filled in, long enough and equal', () => { - let result; - let operations; - - beforeEach(() => { - setModelValue('password', 'testest'); - setModelValue('passwordrepeat', 'testest'); - operations = [{ op: 'replace', path: '/password', value: 'testest' }]; - result = component.updateSecurity(); - }); - - it('should return true', () => { - expect(result).toEqual(true); - }); - - it('should return call epersonService.patch', () => { - expect(epersonService.patch).toHaveBeenCalledWith(user, operations); - }); + expect(component.passwordValue.emit).toHaveBeenCalledWith('new-password'); + })); }); }); - - function setModelValue(id: string, value: string) { - component.formGroup.patchValue({ - [id]: value - }); - component.formGroup.markAllAsTouched(); - } }); diff --git a/src/app/profile-page/profile-page-security-form/profile-page-security-form.component.ts b/src/app/profile-page/profile-page-security-form/profile-page-security-form.component.ts index b8ac07e6d8..1013cad44b 100644 --- a/src/app/profile-page/profile-page-security-form/profile-page-security-form.component.ts +++ b/src/app/profile-page/profile-page-security-form/profile-page-security-form.component.ts @@ -1,16 +1,12 @@ -import { Component, Input, OnInit } from '@angular/core'; -import { - DynamicFormControlModel, - DynamicFormService, - DynamicInputModel -} from '@ng-dynamic-forms/core'; +import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; +import { DynamicFormControlModel, DynamicFormService, DynamicInputModel } from '@ng-dynamic-forms/core'; import { TranslateService } from '@ngx-translate/core'; import { FormGroup } from '@angular/forms'; -import { isEmpty, isNotEmpty } from '../../shared/empty.util'; +import { hasValue, isEmpty } from '../../shared/empty.util'; import { EPersonDataService } from '../../core/eperson/eperson-data.service'; -import { EPerson } from '../../core/eperson/models/eperson.model'; -import { ErrorResponse, RestResponse } from '../../core/cache/response.models'; import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { debounceTime, map } from 'rxjs/operators'; +import { Subscription } from 'rxjs'; @Component({ selector: 'ds-profile-page-security-form', @@ -21,10 +17,15 @@ import { NotificationsService } from '../../shared/notifications/notifications.s * Displays a form containing a password field and a confirmation of the password */ export class ProfilePageSecurityFormComponent implements OnInit { + /** - * The user to display the form for + * Emits the validity of the password */ - @Input() user: EPerson; + @Output() isInvalid = new EventEmitter(); + /** + * Emits the value of the password + */ + @Output() passwordValue = new EventEmitter(); /** * The form's input models @@ -48,14 +49,17 @@ export class ProfilePageSecurityFormComponent implements OnInit { formGroup: FormGroup; /** - * Prefix for the notification messages of this component + * Indicates whether the "checkPasswordEmpty" needs to be added or not */ - NOTIFICATIONS_PREFIX = 'profile.security.form.notifications.'; + @Input() + passwordCanBeEmpty = true; /** * Prefix for the form's label messages of this component */ - LABEL_PREFIX = 'profile.security.form.label.'; + @Input() + FORM_PREFIX: string; + private subs: Subscription[] = []; constructor(protected formService: DynamicFormService, protected translate: TranslateService, @@ -64,12 +68,35 @@ export class ProfilePageSecurityFormComponent implements OnInit { } ngOnInit(): void { - this.formGroup = this.formService.createFormGroup(this.formModel, { validators: [this.checkPasswordsEqual, this.checkPasswordLength] }); + if (this.passwordCanBeEmpty) { + this.formGroup = this.formService.createFormGroup(this.formModel, + {validators: [this.checkPasswordsEqual, this.checkPasswordLength]}); + } else { + this.formGroup = this.formService.createFormGroup(this.formModel, + {validators: [this.checkPasswordsEqual, this.checkPasswordLength, this.checkPasswordEmpty]}); + } this.updateFieldTranslations(); this.translate.onLangChange .subscribe(() => { this.updateFieldTranslations(); }); + + this.subs.push(this.formGroup.statusChanges.pipe( + debounceTime(300), + map((status: string) => { + if (status !== 'VALID') { + return true; + } else { + return false; + } + })).subscribe((status) => this.isInvalid.emit(status)) + ); + + this.subs.push(this.formGroup.valueChanges.pipe( + debounceTime(300), + ).subscribe((valueChange) => { + this.passwordValue.emit(valueChange.password); + })); } /** @@ -78,7 +105,7 @@ export class ProfilePageSecurityFormComponent implements OnInit { updateFieldTranslations() { this.formModel.forEach( (fieldModel: DynamicInputModel) => { - fieldModel.label = this.translate.instant(this.LABEL_PREFIX + fieldModel.id); + fieldModel.label = this.translate.instant(this.FORM_PREFIX + 'label.' + fieldModel.id); } ); } @@ -91,7 +118,7 @@ export class ProfilePageSecurityFormComponent implements OnInit { const pass = group.get('password').value; const repeatPass = group.get('passwordrepeat').value; - return pass === repeatPass ? null : { notSame: true }; + return pass === repeatPass ? null : {notSame: true}; } /** @@ -101,51 +128,24 @@ export class ProfilePageSecurityFormComponent implements OnInit { checkPasswordLength(group: FormGroup) { const pass = group.get('password').value; - return isEmpty(pass) || pass.length >= 6 ? null : { notLongEnough: true }; + return isEmpty(pass) || pass.length >= 6 ? null : {notLongEnough: true}; } /** - * Update the user's security details - * - * Sends a patch request for changing the user's password when a new password is present and the password confirmation - * matches the new password. - * Nothing happens when no passwords are filled in. - * An error notification is displayed when the password confirmation does not match the new password. - * - * Returns false when nothing happened + * Checks if the password is empty + * @param group The FormGroup to validate */ - updateSecurity() { - const pass = this.formGroup.get('password').value; - const passEntered = isNotEmpty(pass); - if (!this.formGroup.valid) { - if (passEntered) { - if (this.checkPasswordsEqual(this.formGroup) != null) { - this.notificationsService.error(this.translate.instant(this.NOTIFICATIONS_PREFIX + 'error.not-same')); - } - if (this.checkPasswordLength(this.formGroup) != null) { - this.notificationsService.error(this.translate.instant(this.NOTIFICATIONS_PREFIX + 'error.not-long-enough')); - } - return true; - } - return false; - } - if (passEntered) { - const operation = Object.assign({ op: 'replace', path: '/password', value: pass }); - this.epersonService.patch(this.user, [operation]).subscribe((response: RestResponse) => { - if (response.isSuccessful) { - this.notificationsService.success( - this.translate.instant(this.NOTIFICATIONS_PREFIX + 'success.title'), - this.translate.instant(this.NOTIFICATIONS_PREFIX + 'success.content') - ); - } else { - this.notificationsService.error( - this.translate.instant(this.NOTIFICATIONS_PREFIX + 'error.title'), (response as ErrorResponse).errorMessage - ); - } - }); + checkPasswordEmpty(group: FormGroup) { + const pass = group.get('password').value; + return isEmpty(pass) ? {emptyPassword: true} : null; + } - } - - return passEntered; + /** + * Unsubscribe from all subscriptions + */ + ngOnDestroy(): void { + this.subs + .filter((sub) => hasValue(sub)) + .forEach((sub) => sub.unsubscribe()); } } diff --git a/src/app/profile-page/profile-page.component.html b/src/app/profile-page/profile-page.component.html index b6e62665b4..ab8f1ce026 100644 --- a/src/app/profile-page/profile-page.component.html +++ b/src/app/profile-page/profile-page.component.html @@ -10,7 +10,11 @@
{{'profile.card.security' | translate}}
- +
diff --git a/src/app/profile-page/profile-page.component.spec.ts b/src/app/profile-page/profile-page.component.spec.ts index d63aba46f5..8d78539bab 100644 --- a/src/app/profile-page/profile-page.component.spec.ts +++ b/src/app/profile-page/profile-page.component.spec.ts @@ -13,8 +13,9 @@ import { NotificationsService } from '../shared/notifications/notifications.serv import { authReducer } from '../core/auth/auth.reducer'; import { createSuccessfulRemoteDataObject$ } from '../shared/remote-data.utils'; import { createPaginatedList } from '../shared/testing/utils.test'; -import { of } from 'rxjs/internal/observable/of'; +import { of as observableOf } from 'rxjs'; import { AuthService } from '../core/auth/auth.service'; +import { RestResponse } from '../core/cache/response.models'; describe('ProfilePageComponent', () => { let component: ProfilePageComponent; @@ -40,10 +41,11 @@ describe('ProfilePageComponent', () => { }; authService = jasmine.createSpyObj('authService', { - getAuthenticatedUserFromStore: of(user) + getAuthenticatedUserFromStore: observableOf(user) }); epersonService = jasmine.createSpyObj('epersonService', { - findById: createSuccessfulRemoteDataObject$(user) + findById: createSuccessfulRemoteDataObject$(user), + patch: observableOf(Object.assign(new RestResponse(true, 200, 'Success'))) }); notificationsService = jasmine.createSpyObj('notificationsService', { success: {}, @@ -84,9 +86,7 @@ describe('ProfilePageComponent', () => { component.metadataForm = jasmine.createSpyObj('metadataForm', { updateProfile: false }); - component.securityForm = jasmine.createSpyObj('securityForm', { - updateSecurity: true - }); + spyOn(component, 'updateSecurity').and.returnValue(true); component.updateProfile(); }); @@ -100,9 +100,6 @@ describe('ProfilePageComponent', () => { component.metadataForm = jasmine.createSpyObj('metadataForm', { updateProfile: true }); - component.securityForm = jasmine.createSpyObj('securityForm', { - updateSecurity: false - }); component.updateProfile(); }); @@ -116,9 +113,6 @@ describe('ProfilePageComponent', () => { component.metadataForm = jasmine.createSpyObj('metadataForm', { updateProfile: true }); - component.securityForm = jasmine.createSpyObj('securityForm', { - updateSecurity: true - }); component.updateProfile(); }); @@ -132,9 +126,6 @@ describe('ProfilePageComponent', () => { component.metadataForm = jasmine.createSpyObj('metadataForm', { updateProfile: false }); - component.securityForm = jasmine.createSpyObj('securityForm', { - updateSecurity: false - }); component.updateProfile(); }); @@ -143,4 +134,60 @@ describe('ProfilePageComponent', () => { }); }); }); + + describe('updateSecurity', () => { + describe('when no password value present', () => { + let result; + + beforeEach(() => { + component.setPasswordValue(''); + + result = component.updateSecurity(); + }); + + it('should return false', () => { + expect(result).toEqual(false); + }); + + it('should not call epersonService.patch', () => { + expect(epersonService.patch).not.toHaveBeenCalled(); + }); + }); + + describe('when password is filled in, but the password is invalid', () => { + let result; + + beforeEach(() => { + component.setPasswordValue('test'); + component.setInvalid(true); + result = component.updateSecurity(); + }); + + it('should return true', () => { + expect(result).toEqual(true); + expect(epersonService.patch).not.toHaveBeenCalled(); + }); + }); + + describe('when password is filled in, and is valid', () => { + let result; + let operations; + + beforeEach(() => { + component.setPasswordValue('testest'); + component.setInvalid(false); + + operations = [{op: 'replace', path: '/password', value: 'testest'}]; + result = component.updateSecurity(); + }); + + it('should return true', () => { + expect(result).toEqual(true); + }); + + it('should return call epersonService.patch', () => { + expect(epersonService.patch).toHaveBeenCalledWith(user, operations); + }); + }); + }); }); diff --git a/src/app/profile-page/profile-page.component.ts b/src/app/profile-page/profile-page.component.ts index 8f4cd492a2..bc06c49f81 100644 --- a/src/app/profile-page/profile-page.component.ts +++ b/src/app/profile-page/profile-page.component.ts @@ -2,7 +2,6 @@ import { Component, OnInit, ViewChild } from '@angular/core'; import { Observable } from 'rxjs/internal/Observable'; import { EPerson } from '../core/eperson/models/eperson.model'; import { ProfilePageMetadataFormComponent } from './profile-page-metadata-form/profile-page-metadata-form.component'; -import { ProfilePageSecurityFormComponent } from './profile-page-security-form/profile-page-security-form.component'; import { NotificationsService } from '../shared/notifications/notifications.service'; import { TranslateService } from '@ngx-translate/core'; import { Group } from '../core/eperson/models/group.model'; @@ -11,9 +10,10 @@ import { PaginatedList } from '../core/data/paginated-list'; import { filter, switchMap, tap } from 'rxjs/operators'; import { EPersonDataService } from '../core/eperson/eperson-data.service'; import { getAllSucceededRemoteData, getRemoteDataPayload } from '../core/shared/operators'; -import { hasValue } from '../shared/empty.util'; +import { hasValue, isNotEmpty } from '../shared/empty.util'; import { followLink } from '../shared/utils/follow-link-config.model'; import { AuthService } from '../core/auth/auth.service'; +import { ErrorResponse, RestResponse } from '../core/cache/response.models'; @Component({ selector: 'ds-profile-page', @@ -26,15 +26,10 @@ export class ProfilePageComponent implements OnInit { /** * A reference to the metadata form component */ - @ViewChild(ProfilePageMetadataFormComponent, { static: false }) metadataForm: ProfilePageMetadataFormComponent; + @ViewChild(ProfilePageMetadataFormComponent, {static: false}) metadataForm: ProfilePageMetadataFormComponent; /** - * A reference to the security form component - */ - @ViewChild(ProfilePageSecurityFormComponent, { static: false }) securityForm: ProfilePageSecurityFormComponent; - - /** - * The authenticated user + * The authenticated user as observable */ user$: Observable; @@ -48,6 +43,26 @@ export class ProfilePageComponent implements OnInit { */ NOTIFICATIONS_PREFIX = 'profile.notifications.'; + /** + * Prefix for the notification messages of this security form + */ + PASSWORD_NOTIFICATIONS_PREFIX = 'profile.security.form.notifications.'; + + /** + * The validity of the password filled in, in the security form + */ + private invalidSecurity: boolean; + + /** + * The password filled in, in the security form + */ + private password: string; + + /** + * The authenticated user + */ + private currentUser: EPerson; + constructor(private authService: AuthService, private notificationsService: NotificationsService, private translate: TranslateService, @@ -59,7 +74,8 @@ export class ProfilePageComponent implements OnInit { filter((user: EPerson) => hasValue(user.id)), switchMap((user: EPerson) => this.epersonService.findById(user.id, followLink('groups'))), getAllSucceededRemoteData(), - getRemoteDataPayload() + getRemoteDataPayload(), + tap((user: EPerson) => this.currentUser = user) ); this.groupsRD$ = this.user$.pipe(switchMap((user: EPerson) => user.groups)); } @@ -70,7 +86,7 @@ export class ProfilePageComponent implements OnInit { */ updateProfile() { const metadataChanged = this.metadataForm.updateProfile(); - const securityChanged = this.securityForm.updateSecurity(); + const securityChanged = this.updateSecurity(); if (!metadataChanged && !securityChanged) { this.notificationsService.warning( this.translate.instant(this.NOTIFICATIONS_PREFIX + 'warning.no-changes.title'), @@ -78,4 +94,61 @@ export class ProfilePageComponent implements OnInit { ); } } + + /** + * Sets the validity of the password based on an emitted of the form + * @param $event + */ + setInvalid($event: boolean) { + this.invalidSecurity = $event; + } + + /** + * Update the user's security details + * + * Sends a patch request for changing the user's password when a new password is present and the password confirmation + * matches the new password. + * Nothing happens when no passwords are filled in. + * An error notification is displayed when the password confirmation does not match the new password. + * + * Returns false when the password was empty + */ + updateSecurity() { + const passEntered = isNotEmpty(this.password); + + if (this.invalidSecurity) { + this.notificationsService.error(this.translate.instant(this.PASSWORD_NOTIFICATIONS_PREFIX + 'error.general')); + } + if (!this.invalidSecurity && passEntered) { + const operation = Object.assign({op: 'replace', path: '/password', value: this.password}); + this.epersonService.patch(this.currentUser, [operation]).subscribe((response: RestResponse) => { + if (response.isSuccessful) { + this.notificationsService.success( + this.translate.instant(this.PASSWORD_NOTIFICATIONS_PREFIX + 'success.title'), + this.translate.instant(this.PASSWORD_NOTIFICATIONS_PREFIX + 'success.content') + ); + } else { + this.notificationsService.error( + this.translate.instant(this.PASSWORD_NOTIFICATIONS_PREFIX + 'error.title'), (response as ErrorResponse).errorMessage + ); + } + }); + } + return passEntered; + } + + /** + * Set the password value based on the value emitted from the security form + * @param $event + */ + setPasswordValue($event: string) { + this.password = $event; + } + + /** + * Submit of the security form that triggers the updateProfile method + */ + submit() { + this.updateProfile(); + } } diff --git a/src/app/profile-page/profile-page.module.ts b/src/app/profile-page/profile-page.module.ts index f40c125ff8..54b59c97ce 100644 --- a/src/app/profile-page/profile-page.module.ts +++ b/src/app/profile-page/profile-page.module.ts @@ -12,6 +12,9 @@ import { ProfilePageSecurityFormComponent } from './profile-page-security-form/p CommonModule, SharedModule ], + exports: [ + ProfilePageSecurityFormComponent + ], declarations: [ ProfilePageComponent, ProfilePageMetadataFormComponent, diff --git a/src/app/register-email-form/register-email-form.component.html b/src/app/register-email-form/register-email-form.component.html new file mode 100644 index 0000000000..e47eedb6ae --- /dev/null +++ b/src/app/register-email-form/register-email-form.component.html @@ -0,0 +1,36 @@ +
+

{{MESSAGE_PREFIX + '.header'|translate}}

+

{{MESSAGE_PREFIX + '.info' | translate}}

+ +
+ +
+
+
+ + +
+ + {{ MESSAGE_PREFIX + '.email.error.required' | translate }} + + + {{ MESSAGE_PREFIX + '.email.error.pattern' | translate }} + +
+
+
+ {{MESSAGE_PREFIX + '.email.hint' |translate}} +
+ +
+ +
+
+ + +
diff --git a/src/app/register-email-form/register-email-form.component.spec.ts b/src/app/register-email-form/register-email-form.component.spec.ts new file mode 100644 index 0000000000..8faaa4021d --- /dev/null +++ b/src/app/register-email-form/register-email-form.component.spec.ts @@ -0,0 +1,92 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { of as observableOf } from 'rxjs'; +import { RestResponse } from '../core/cache/response.models'; +import { CommonModule } from '@angular/common'; +import { RouterTestingModule } from '@angular/router/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { FormBuilder, ReactiveFormsModule } from '@angular/forms'; +import { Router } from '@angular/router'; +import { NotificationsService } from '../shared/notifications/notifications.service'; +import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; +import { EpersonRegistrationService } from '../core/data/eperson-registration.service'; +import { By } from '@angular/platform-browser'; +import { RouterStub } from '../shared/testing/router.stub'; +import { NotificationsServiceStub } from '../shared/testing/notifications-service.stub'; +import { RegisterEmailFormComponent } from './register-email-form.component'; + +describe('RegisterEmailComponent', () => { + + let comp: RegisterEmailFormComponent; + let fixture: ComponentFixture; + + let router; + let epersonRegistrationService: EpersonRegistrationService; + let notificationsService; + + beforeEach(async(() => { + + router = new RouterStub(); + notificationsService = new NotificationsServiceStub(); + + epersonRegistrationService = jasmine.createSpyObj('epersonRegistrationService', { + registerEmail: observableOf(new RestResponse(true, 200, 'Success')) + }); + + TestBed.configureTestingModule({ + imports: [CommonModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), ReactiveFormsModule], + declarations: [RegisterEmailFormComponent], + providers: [ + {provide: Router, useValue: router}, + {provide: EpersonRegistrationService, useValue: epersonRegistrationService}, + {provide: FormBuilder, useValue: new FormBuilder()}, + {provide: NotificationsService, useValue: notificationsService}, + ], + schemas: [CUSTOM_ELEMENTS_SCHEMA] + }).compileComponents(); + })); + beforeEach(() => { + fixture = TestBed.createComponent(RegisterEmailFormComponent); + comp = fixture.componentInstance; + + fixture.detectChanges(); + }); + describe('init', () => { + it('should initialise the form', () => { + const elem = fixture.debugElement.queryAll(By.css('input#email'))[0].nativeElement; + expect(elem).toBeDefined(); + }); + }); + describe('email validation', () => { + it('should be invalid when no email is present', () => { + expect(comp.form.invalid).toBeTrue(); + }); + it('should be invalid when no valid email is present', () => { + comp.form.patchValue({email: 'invalid'}); + expect(comp.form.invalid).toBeTrue(); + }); + it('should be valid when a valid email is present', () => { + comp.form.patchValue({email: 'valid@email.org'}); + expect(comp.form.invalid).toBeFalse(); + }); + }); + describe('register', () => { + it('should send a registration to the service and on success display a message and return to home', () => { + comp.form.patchValue({email: 'valid@email.org'}); + + comp.register(); + expect(epersonRegistrationService.registerEmail).toHaveBeenCalledWith('valid@email.org'); + expect(notificationsService.success).toHaveBeenCalled(); + expect(router.navigate).toHaveBeenCalledWith(['/home']); + }); + it('should send a registration to the service and on error display a message', () => { + (epersonRegistrationService.registerEmail as jasmine.Spy).and.returnValue(observableOf(new RestResponse(false, 400, 'Bad Request'))); + + comp.form.patchValue({email: 'valid@email.org'}); + + comp.register(); + expect(epersonRegistrationService.registerEmail).toHaveBeenCalledWith('valid@email.org'); + expect(notificationsService.error).toHaveBeenCalled(); + expect(router.navigate).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/app/register-email-form/register-email-form.component.ts b/src/app/register-email-form/register-email-form.component.ts new file mode 100644 index 0000000000..59fe3791e0 --- /dev/null +++ b/src/app/register-email-form/register-email-form.component.ts @@ -0,0 +1,73 @@ +import { Component, Input, OnInit } from '@angular/core'; +import { EpersonRegistrationService } from '../core/data/eperson-registration.service'; +import { RestResponse } from '../core/cache/response.models'; +import { NotificationsService } from '../shared/notifications/notifications.service'; +import { TranslateService } from '@ngx-translate/core'; +import { Router } from '@angular/router'; +import { FormBuilder, FormControl, FormGroup, Validators } from '@angular/forms'; + +@Component({ + selector: 'ds-register-email-form', + templateUrl: './register-email-form.component.html' +}) +/** + * Component responsible to render an email registration form. + */ +export class RegisterEmailFormComponent implements OnInit { + + /** + * The form containing the mail address + */ + form: FormGroup; + + /** + * The message prefix + */ + @Input() + MESSAGE_PREFIX: string; + + constructor( + private epersonRegistrationService: EpersonRegistrationService, + private notificationService: NotificationsService, + private translateService: TranslateService, + private router: Router, + private formBuilder: FormBuilder + ) { + + } + + ngOnInit(): void { + this.form = this.formBuilder.group({ + email: new FormControl('', { + validators: [Validators.required, + Validators.pattern('^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,4}$') + ], + }) + }); + + } + + /** + * Register an email address + */ + register() { + if (!this.form.invalid) { + this.epersonRegistrationService.registerEmail(this.email.value).subscribe((response: RestResponse) => { + if (response.isSuccessful) { + this.notificationService.success(this.translateService.get(`${this.MESSAGE_PREFIX}.success.head`), + this.translateService.get(`${this.MESSAGE_PREFIX}.success.content`, {email: this.email.value})); + this.router.navigate(['/home']); + } else { + this.notificationService.error(this.translateService.get(`${this.MESSAGE_PREFIX}.error.head`), + this.translateService.get(`${this.MESSAGE_PREFIX}.error.content`, {email: this.email.value})); + } + } + ); + } + } + + get email() { + return this.form.get('email'); + } + +} diff --git a/src/app/register-email-form/register-email-form.module.ts b/src/app/register-email-form/register-email-form.module.ts new file mode 100644 index 0000000000..a5fe0899a9 --- /dev/null +++ b/src/app/register-email-form/register-email-form.module.ts @@ -0,0 +1,26 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { SharedModule } from '../shared/shared.module'; +import { RegisterEmailFormComponent } from './register-email-form.component'; + +@NgModule({ + imports: [ + CommonModule, + SharedModule, + ], + declarations: [ + RegisterEmailFormComponent, + ], + providers: [], + exports: [ + RegisterEmailFormComponent, + ], + entryComponents: [] +}) + +/** + * The module that contains the components related to the email registration + */ +export class RegisterEmailFormModule { + +} diff --git a/src/app/register-email-form/registration.resolver.spec.ts b/src/app/register-email-form/registration.resolver.spec.ts new file mode 100644 index 0000000000..8feef919a7 --- /dev/null +++ b/src/app/register-email-form/registration.resolver.spec.ts @@ -0,0 +1,33 @@ +import { RegistrationResolver } from './registration.resolver'; +import { EpersonRegistrationService } from '../core/data/eperson-registration.service'; +import { of as observableOf } from 'rxjs'; +import { Registration } from '../core/shared/registration.model'; +import { first } from 'rxjs/operators'; + +describe('RegistrationResolver', () => { + let resolver: RegistrationResolver; + let epersonRegistrationService: EpersonRegistrationService; + + const token = 'test-token'; + const registration = Object.assign(new Registration(), {email: 'test@email.org', token: token, user:'user-uuid'}); + + beforeEach(() => { + epersonRegistrationService = jasmine.createSpyObj('epersonRegistrationService', { + searchByToken: observableOf(registration) + }); + resolver = new RegistrationResolver(epersonRegistrationService); + }); + describe('resolve', () => { + it('should resolve a registration based on the token', () => { + resolver.resolve({params: {token: token}} as any, undefined) + .pipe(first()) + .subscribe( + (resolved) => { + expect(resolved.token).toEqual(token); + expect(resolved.email).toEqual('test@email.org'); + expect(resolved.user).toEqual('user-uuid'); + } + ); + }); + }); +}); diff --git a/src/app/register-email-form/registration.resolver.ts b/src/app/register-email-form/registration.resolver.ts new file mode 100644 index 0000000000..64ca2ba9f4 --- /dev/null +++ b/src/app/register-email-form/registration.resolver.ts @@ -0,0 +1,20 @@ +import { Injectable } from '@angular/core'; +import { ActivatedRouteSnapshot, Resolve, RouterStateSnapshot } from '@angular/router'; +import { EpersonRegistrationService } from '../core/data/eperson-registration.service'; +import { Registration } from '../core/shared/registration.model'; +import { Observable } from 'rxjs'; + +@Injectable() +/** + * Resolver to resolve a Registration object based on the provided token + */ +export class RegistrationResolver implements Resolve { + + constructor(private epersonRegistrationService: EpersonRegistrationService) { + } + + resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { + const token = route.params.token; + return this.epersonRegistrationService.searchByToken(token); + } +} diff --git a/src/app/register-page/create-profile/confirmed.validator.spec.ts b/src/app/register-page/create-profile/confirmed.validator.spec.ts new file mode 100644 index 0000000000..9bbbda9f29 --- /dev/null +++ b/src/app/register-page/create-profile/confirmed.validator.spec.ts @@ -0,0 +1,32 @@ +import { FormBuilder, FormControl } from '@angular/forms'; +import { async, fakeAsync } from '@angular/core/testing'; +import { ConfirmedValidator } from './confirmed.validator'; + +describe('ConfirmedValidator', () => { + let passwordForm; + + beforeEach(async(() => { + + passwordForm = (new FormBuilder()).group({ + password: new FormControl('', {}), + confirmPassword: new FormControl('', {}) + }, { + validator: ConfirmedValidator('password', 'confirmPassword') + }); + })); + + it('should validate that the password and confirm password match', fakeAsync(() => { + + passwordForm.get('password').patchValue('test-password'); + passwordForm.get('confirmPassword').patchValue('test-password-mismatch'); + + expect(passwordForm.valid).toBe(false); + })); + + it('should invalidate that the password and confirm password match', fakeAsync(() => { + passwordForm.get('password').patchValue('test-password'); + passwordForm.get('confirmPassword').patchValue('test-password'); + + expect(passwordForm.valid).toBe(true); + })); +}); diff --git a/src/app/register-page/create-profile/confirmed.validator.ts b/src/app/register-page/create-profile/confirmed.validator.ts new file mode 100644 index 0000000000..30f4f01701 --- /dev/null +++ b/src/app/register-page/create-profile/confirmed.validator.ts @@ -0,0 +1,19 @@ +import { FormGroup } from '@angular/forms'; + +/** + * Validator used to confirm that the password and confirmed password value are the same + */ +export function ConfirmedValidator(controlName: string, matchingControlName: string) { + return (formGroup: FormGroup) => { + const control = formGroup.controls[controlName]; + const matchingControl = formGroup.controls[matchingControlName]; + if (matchingControl.errors && !matchingControl.errors.confirmedValidator) { + return; + } + if (control.value !== matchingControl.value) { + matchingControl.setErrors({confirmedValidator: true}); + } else { + matchingControl.setErrors(null); + } + }; +} diff --git a/src/app/register-page/create-profile/create-profile.component.html b/src/app/register-page/create-profile/create-profile.component.html new file mode 100644 index 0000000000..cfd6e7ab16 --- /dev/null +++ b/src/app/register-page/create-profile/create-profile.component.html @@ -0,0 +1,91 @@ +
+

{{'register-page.create-profile.header' | translate}}

+
+
{{'register-page.create-profile.identification.header' | translate}}
+
+
+
+ + {{(registration$ |async).email}}
+
+
+ +
+
+
+ + +
+ + {{ 'register-page.create-profile.identification.first-name.error' | translate }} + +
+
+ +
+
+
+ + +
+ + {{ 'register-page.create-profile.identification.last-name.error' | translate }} + +
+
+
+
+
+ + +
+
+
+
+ + + +
+
+
+
+
+
+ +
+
{{'register-page.create-profile.security.header' | translate}}
+
+ + +
+
+ + +
+
+ +
+
+ + +
diff --git a/src/app/register-page/create-profile/create-profile.component.spec.ts b/src/app/register-page/create-profile/create-profile.component.spec.ts new file mode 100644 index 0000000000..a435e1143e --- /dev/null +++ b/src/app/register-page/create-profile/create-profile.component.spec.ts @@ -0,0 +1,184 @@ +import { CreateProfileComponent } from './create-profile.component'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { Registration } from '../../core/shared/registration.model'; +import { CommonModule } from '@angular/common'; +import { RouterTestingModule } from '@angular/router/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { FormBuilder, ReactiveFormsModule } from '@angular/forms'; +import { EPersonDataService } from '../../core/eperson/eperson-data.service'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { of as observableOf } from 'rxjs'; +import { RestResponse } from '../../core/cache/response.models'; +import { By } from '@angular/platform-browser'; +import { CoreState } from '../../core/core.reducers'; +import { EPerson } from '../../core/eperson/models/eperson.model'; +import { AuthenticateAction } from '../../core/auth/auth.actions'; +import { RouterStub } from '../../shared/testing/router.stub'; +import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub'; + +describe('CreateProfileComponent', () => { + let comp: CreateProfileComponent; + let fixture: ComponentFixture; + + let router; + let route; + let ePersonDataService: EPersonDataService; + let notificationsService; + let store: Store; + + const registration = Object.assign(new Registration(), {email: 'test@email.org', token: 'test-token'}); + + const values = { + metadata: { + 'eperson.firstname': [ + { + value: 'First' + } + ], + 'eperson.lastname': [ + { + value: 'Last' + }, + ], + 'eperson.phone': [ + { + value: 'Phone' + } + ], + 'eperson.language': [ + { + value: 'en' + } + ] + }, + email: 'test@email.org', + password: 'password', + canLogIn: true, + requireCertificate: false + }; + const eperson = Object.assign(new EPerson(), values); + + beforeEach(async(() => { + route = {data: observableOf({registration: registration})}; + router = new RouterStub(); + notificationsService = new NotificationsServiceStub(); + + ePersonDataService = jasmine.createSpyObj('ePersonDataService', { + createEPersonForToken: observableOf(new RestResponse(true, 200, 'Success')) + }); + + store = jasmine.createSpyObj('store', { + dispatch: {}, + }); + + TestBed.configureTestingModule({ + imports: [CommonModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), ReactiveFormsModule], + declarations: [CreateProfileComponent], + providers: [ + {provide: Router, useValue: router}, + {provide: ActivatedRoute, useValue: route}, + {provide: Store, useValue: store}, + {provide: EPersonDataService, useValue: ePersonDataService}, + {provide: FormBuilder, useValue: new FormBuilder()}, + {provide: NotificationsService, useValue: notificationsService}, + ], + schemas: [CUSTOM_ELEMENTS_SCHEMA] + }).compileComponents(); + })); + beforeEach(() => { + fixture = TestBed.createComponent(CreateProfileComponent); + comp = fixture.componentInstance; + + fixture.detectChanges(); + }); + + describe('init', () => { + it('should initialise mail address', () => { + const elem = fixture.debugElement.queryAll(By.css('span#email'))[0].nativeElement; + expect(elem.innerHTML).toContain('test@email.org'); + }); + it('should initialise the form', () => { + const firstName = fixture.debugElement.queryAll(By.css('input#firstName'))[0].nativeElement; + const lastName = fixture.debugElement.queryAll(By.css('input#lastName'))[0].nativeElement; + const contactPhone = fixture.debugElement.queryAll(By.css('input#contactPhone'))[0].nativeElement; + const language = fixture.debugElement.queryAll(By.css('select#language'))[0].nativeElement; + + expect(firstName).toBeDefined(); + expect(lastName).toBeDefined(); + expect(contactPhone).toBeDefined(); + expect(language).toBeDefined(); + }); + }); + + describe('submitEperson', () => { + + it('should submit an eperson for creation and log in on success', () => { + comp.firstName.patchValue('First'); + comp.lastName.patchValue('Last'); + comp.contactPhone.patchValue('Phone'); + comp.language.patchValue('en'); + comp.password = 'password'; + comp.isInValidPassword = false; + + comp.submitEperson(); + + expect(ePersonDataService.createEPersonForToken).toHaveBeenCalledWith(eperson, 'test-token'); + expect(store.dispatch).toHaveBeenCalledWith(new AuthenticateAction('test@email.org', 'password')); + expect(router.navigate).toHaveBeenCalledWith(['/home']); + expect(notificationsService.success).toHaveBeenCalled(); + }); + + it('should submit an eperson for creation and stay on page on error', () => { + + (ePersonDataService.createEPersonForToken as jasmine.Spy).and.returnValue(observableOf(new RestResponse(false, 500, 'Error'))); + + comp.firstName.patchValue('First'); + comp.lastName.patchValue('Last'); + comp.contactPhone.patchValue('Phone'); + comp.language.patchValue('en'); + comp.password = 'password'; + comp.isInValidPassword = false; + + comp.submitEperson(); + + expect(ePersonDataService.createEPersonForToken).toHaveBeenCalledWith(eperson, 'test-token'); + expect(store.dispatch).not.toHaveBeenCalled(); + expect(router.navigate).not.toHaveBeenCalled(); + expect(notificationsService.error).toHaveBeenCalled(); + }); + it('should submit not create an eperson when the user info form is invalid', () => { + + (ePersonDataService.createEPersonForToken as jasmine.Spy).and.returnValue(observableOf(new RestResponse(false, 500, 'Error'))); + + comp.firstName.patchValue(''); + comp.lastName.patchValue('Last'); + comp.contactPhone.patchValue('Phone'); + comp.language.patchValue('en'); + comp.password = 'password'; + comp.isInValidPassword = false; + + comp.submitEperson(); + + expect(ePersonDataService.createEPersonForToken).not.toHaveBeenCalled(); + }); + it('should submit not create an eperson when the password is invalid', () => { + + (ePersonDataService.createEPersonForToken as jasmine.Spy).and.returnValue(observableOf(new RestResponse(false, 500, 'Error'))); + + comp.firstName.patchValue('First'); + comp.lastName.patchValue('Last'); + comp.contactPhone.patchValue('Phone'); + comp.language.patchValue('en'); + comp.password = 'password'; + comp.isInValidPassword = true; + + comp.submitEperson(); + + expect(ePersonDataService.createEPersonForToken).not.toHaveBeenCalled(); + }); + + }); +}); diff --git a/src/app/register-page/create-profile/create-profile.component.ts b/src/app/register-page/create-profile/create-profile.component.ts new file mode 100644 index 0000000000..2755a17739 --- /dev/null +++ b/src/app/register-page/create-profile/create-profile.component.ts @@ -0,0 +1,155 @@ +import { Component, OnInit } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { map } from 'rxjs/operators'; +import { Registration } from '../../core/shared/registration.model'; +import { Observable } from 'rxjs'; +import { FormBuilder, FormControl, FormGroup, Validators } from '@angular/forms'; +import { TranslateService } from '@ngx-translate/core'; +import { EPersonDataService } from '../../core/eperson/eperson-data.service'; +import { EPerson } from '../../core/eperson/models/eperson.model'; +import { LangConfig } from '../../../config/lang-config.interface'; +import { Store } from '@ngrx/store'; +import { CoreState } from '../../core/core.reducers'; +import { AuthenticateAction } from '../../core/auth/auth.actions'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { environment } from '../../../environments/environment'; +import { isEmpty } from '../../shared/empty.util'; + +/** + * Component that renders the create profile page to be used by a user registering through a token + */ +@Component({ + selector: 'ds-create-profile', + templateUrl: './create-profile.component.html' +}) +export class CreateProfileComponent implements OnInit { + registration$: Observable; + + email: string; + token: string; + + isInValidPassword = true; + password: string; + + userInfoForm: FormGroup; + activeLangs: LangConfig[]; + + constructor( + private translateService: TranslateService, + private ePersonDataService: EPersonDataService, + private store: Store, + private router: Router, + private route: ActivatedRoute, + private formBuilder: FormBuilder, + private notificationsService: NotificationsService + ) { + + } + + ngOnInit(): void { + this.registration$ = this.route.data.pipe( + map((data) => data.registration as Registration), + ); + this.registration$.subscribe((registration: Registration) => { + this.email = registration.email; + this.token = registration.token; + }); + this.activeLangs = environment.languages.filter((MyLangConfig) => MyLangConfig.active === true); + + this.userInfoForm = this.formBuilder.group({ + firstName: new FormControl('', { + validators: [Validators.required], + }), + lastName: new FormControl('', { + validators: [Validators.required], + }), + contactPhone: new FormControl(''), + language: new FormControl(''), + }); + + } + + /** + * Sets the validity of the password based on a value emitted from the form + * @param $event + */ + setInValid($event: boolean) { + this.isInValidPassword = $event || isEmpty(this.password); + } + + /** + * Sets the value of the password based on a value emitted from the form + * @param $event + */ + setPasswordValue($event: string) { + this.password = $event; + this.isInValidPassword = this.isInValidPassword || isEmpty(this.password); + } + + get firstName() { + return this.userInfoForm.get('firstName'); + } + + get lastName() { + return this.userInfoForm.get('lastName'); + } + + get contactPhone() { + return this.userInfoForm.get('contactPhone'); + } + + get language() { + return this.userInfoForm.get('language'); + } + + /** + * Submits the eperson to the service to be created. + * The submission will not be made when the form or the password is not valid. + */ + submitEperson() { + if (!(this.userInfoForm.invalid || this.isInValidPassword)) { + const values = { + metadata: { + 'eperson.firstname': [ + { + value: this.firstName.value + } + ], + 'eperson.lastname': [ + { + value: this.lastName.value + }, + ], + 'eperson.phone': [ + { + value: this.contactPhone.value + } + ], + 'eperson.language': [ + { + value: this.language.value + } + ] + }, + email: this.email, + password: this.password, + canLogIn: true, + requireCertificate: false + }; + + const eperson = Object.assign(new EPerson(), values); + this.ePersonDataService.createEPersonForToken(eperson, this.token).subscribe((response) => { + if (response.isSuccessful) { + this.notificationsService.success(this.translateService.get('register-page.create-profile.submit.success.head'), + this.translateService.get('register-page.create-profile.submit.success.content')); + this.store.dispatch(new AuthenticateAction(this.email, this.password)); + this.router.navigate(['/home']); + } else { + this.notificationsService.error(this.translateService.get('register-page.create-profile.submit.error.head'), + this.translateService.get('register-page.create-profile.submit.error.content')); + } + }); + } + } + +} diff --git a/src/app/register-page/register-email/register-email.component.html b/src/app/register-page/register-email/register-email.component.html new file mode 100644 index 0000000000..a60dc4c31e --- /dev/null +++ b/src/app/register-page/register-email/register-email.component.html @@ -0,0 +1,3 @@ + + diff --git a/src/app/register-page/register-email/register-email.component.spec.ts b/src/app/register-page/register-email/register-email.component.spec.ts new file mode 100644 index 0000000000..74bd247d74 --- /dev/null +++ b/src/app/register-page/register-email/register-email.component.spec.ts @@ -0,0 +1,30 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { CommonModule } from '@angular/common'; +import { TranslateModule } from '@ngx-translate/core'; +import { ReactiveFormsModule } from '@angular/forms'; +import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; +import { RegisterEmailComponent } from './register-email.component'; + +describe('RegisterEmailComponent', () => { + + let comp: RegisterEmailComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [CommonModule, TranslateModule.forRoot(), ReactiveFormsModule], + declarations: [RegisterEmailComponent], + schemas: [CUSTOM_ELEMENTS_SCHEMA] + }).compileComponents(); + })); + beforeEach(() => { + fixture = TestBed.createComponent(RegisterEmailComponent); + comp = fixture.componentInstance; + + fixture.detectChanges(); + }); + + it('should be defined', () => { + expect(comp).toBeDefined(); + }); +}); diff --git a/src/app/register-page/register-email/register-email.component.ts b/src/app/register-page/register-email/register-email.component.ts new file mode 100644 index 0000000000..ac221c109a --- /dev/null +++ b/src/app/register-page/register-email/register-email.component.ts @@ -0,0 +1,12 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'ds-register-email', + templateUrl: './register-email.component.html' +}) +/** + * Component responsible the email registration step when registering as a new user + */ +export class RegisterEmailComponent { + +} diff --git a/src/app/register-page/register-page-routing.module.ts b/src/app/register-page/register-page-routing.module.ts new file mode 100644 index 0000000000..c7cceeaaf4 --- /dev/null +++ b/src/app/register-page/register-page-routing.module.ts @@ -0,0 +1,32 @@ +import { NgModule } from '@angular/core'; +import { RouterModule } from '@angular/router'; +import { RegisterEmailComponent } from './register-email/register-email.component'; +import { CreateProfileComponent } from './create-profile/create-profile.component'; +import { ItemPageResolver } from '../+item-page/item-page.resolver'; +import { RegistrationResolver } from '../register-email-form/registration.resolver'; + +@NgModule({ + imports: [ + RouterModule.forChild([ + { + path: '', + component: RegisterEmailComponent, + data: {title: 'register-email.title'}, + }, + { + path: ':token', + component: CreateProfileComponent, + resolve: {registration: RegistrationResolver} + } + ]) + ], + providers: [ + RegistrationResolver, + ItemPageResolver + ] +}) +/** + * Module related to the navigation to components used to register a new user + */ +export class RegisterPageRoutingModule { +} diff --git a/src/app/register-page/register-page.module.ts b/src/app/register-page/register-page.module.ts new file mode 100644 index 0000000000..b29d2ecaaf --- /dev/null +++ b/src/app/register-page/register-page.module.ts @@ -0,0 +1,31 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { SharedModule } from '../shared/shared.module'; +import { RegisterPageRoutingModule } from './register-page-routing.module'; +import { RegisterEmailComponent } from './register-email/register-email.component'; +import { CreateProfileComponent } from './create-profile/create-profile.component'; +import { RegisterEmailFormModule } from '../register-email-form/register-email-form.module'; +import { ProfilePageModule } from '../profile-page/profile-page.module'; + +@NgModule({ + imports: [ + CommonModule, + SharedModule, + RegisterPageRoutingModule, + RegisterEmailFormModule, + ProfilePageModule, + ], + declarations: [ + RegisterEmailComponent, + CreateProfileComponent + ], + providers: [], + entryComponents: [] +}) + +/** + * Module related to components used to register a new user + */ +export class RegisterPageModule { + +} diff --git a/src/app/shared/file-download-link/file-download-link.component.html b/src/app/shared/file-download-link/file-download-link.component.html new file mode 100644 index 0000000000..06624c8b40 --- /dev/null +++ b/src/app/shared/file-download-link/file-download-link.component.html @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/app/shared/file-download-link/file-download-link.component.scss b/src/app/shared/file-download-link/file-download-link.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/shared/file-download-link/file-download-link.component.spec.ts b/src/app/shared/file-download-link/file-download-link.component.spec.ts new file mode 100644 index 0000000000..ac1751d43d --- /dev/null +++ b/src/app/shared/file-download-link/file-download-link.component.spec.ts @@ -0,0 +1,57 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { FileDownloadLinkComponent } from './file-download-link.component'; +import { AuthService } from '../../core/auth/auth.service'; +import { FileService } from '../../core/shared/file.service'; +import { of as observableOf } from 'rxjs'; + +describe('FileDownloadLinkComponent', () => { + let component: FileDownloadLinkComponent; + let fixture: ComponentFixture; + + let authService: AuthService; + let fileService: FileService; + let href: string; + + function init() { + authService = jasmine.createSpyObj('authService', { + isAuthenticated: observableOf(true) + }); + fileService = jasmine.createSpyObj('fileService', ['downloadFile']); + href = 'test-download-file-link'; + } + + beforeEach(async(() => { + init(); + TestBed.configureTestingModule({ + declarations: [ FileDownloadLinkComponent ], + providers: [ + { provide: AuthService, useValue: authService }, + { provide: FileService, useValue: fileService } + ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(FileDownloadLinkComponent); + component = fixture.componentInstance; + component.href = href; + fixture.detectChanges(); + }); + + describe('downloadFile', () => { + let result; + + beforeEach(() => { + result = component.downloadFile(); + }); + + it('should call fileService.downloadFile with the provided href', () => { + expect(fileService.downloadFile).toHaveBeenCalledWith(href); + }); + + it('should return false', () => { + expect(result).toEqual(false); + }); + }); +}); diff --git a/src/app/shared/file-download-link/file-download-link.component.ts b/src/app/shared/file-download-link/file-download-link.component.ts new file mode 100644 index 0000000000..9df7c191ff --- /dev/null +++ b/src/app/shared/file-download-link/file-download-link.component.ts @@ -0,0 +1,48 @@ +import { Component, Input, OnInit } from '@angular/core'; +import { FileService } from '../../core/shared/file.service'; +import { Observable } from 'rxjs/internal/Observable'; +import { AuthService } from '../../core/auth/auth.service'; + +@Component({ + selector: 'ds-file-download-link', + templateUrl: './file-download-link.component.html', + styleUrls: ['./file-download-link.component.scss'] +}) +/** + * Component displaying a download link + * When the user is authenticated, a short-lived token retrieved from the REST API is added to the download link, + * ensuring the user is authorized to download the file. + */ +export class FileDownloadLinkComponent implements OnInit { + /** + * Href to link to + */ + @Input() href: string; + + /** + * Optional file name for the download + */ + @Input() download: string; + + /** + * Whether or not the current user is authenticated + */ + isAuthenticated$: Observable; + + constructor(private fileService: FileService, + private authService: AuthService) { } + + ngOnInit() { + this.isAuthenticated$ = this.authService.isAuthenticated(); + } + + /** + * Start a download of the file + * Return false to ensure the original href is displayed when the user hovers over the link + */ + downloadFile(): boolean { + this.fileService.downloadFile(this.href); + return false; + } + +} diff --git a/src/app/shared/log-in/log-in.component.html b/src/app/shared/log-in/log-in.component.html index 8e23f00d9b..5285bc65e4 100644 --- a/src/app/shared/log-in/log-in.component.html +++ b/src/app/shared/log-in/log-in.component.html @@ -8,6 +8,6 @@ - {{"login.form.new-user" | translate}} - {{"login.form.forgot-password" | translate}} + {{"login.form.new-user" | translate}} + {{"login.form.forgot-password" | translate}} 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 0167d61686..a9a42bf3dd 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,7 @@ import { Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; import { async, ComponentFixture, inject, TestBed } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; -import { Store, StoreModule } from '@ngrx/store'; +import { StoreModule } from '@ngrx/store'; import { LogInComponent } from './log-in.component'; import { authReducer } from '../../core/auth/auth.reducer'; @@ -13,11 +13,11 @@ import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { SharedModule } from '../shared.module'; import { NativeWindowMockFactory } from '../mocks/mock-native-window-ref'; import { ActivatedRouteStub } from '../testing/active-router.stub'; -import { ActivatedRoute, Router } from '@angular/router'; -import { RouterStub } from '../testing/router.stub'; +import { ActivatedRoute } from '@angular/router'; import { NativeWindowService } from '../../core/services/window.service'; import { provideMockStore } from '@ngrx/store/testing'; import { createTestComponent } from '../testing/utils.test'; +import { RouterTestingModule } from '@angular/router/testing'; describe('LogInComponent', () => { @@ -46,6 +46,7 @@ describe('LogInComponent', () => { strictActionImmutability: false } }), + RouterTestingModule, SharedModule, TranslateModule.forRoot() ], @@ -55,7 +56,7 @@ describe('LogInComponent', () => { providers: [ { provide: AuthService, useClass: AuthServiceStub }, { provide: NativeWindowService, useFactory: NativeWindowMockFactory }, - { provide: Router, useValue: new RouterStub() }, + // { provide: Router, useValue: new RouterStub() }, { provide: ActivatedRoute, useValue: new ActivatedRouteStub() }, provideMockStore({ initialState }), LogInComponent diff --git a/src/app/shared/log-in/log-in.component.ts b/src/app/shared/log-in/log-in.component.ts index 92350de442..32e10fef45 100644 --- a/src/app/shared/log-in/log-in.component.ts +++ b/src/app/shared/log-in/log-in.component.ts @@ -8,6 +8,7 @@ import { AuthMethod } from '../../core/auth/models/auth.method'; import { getAuthenticationMethods, isAuthenticated, isAuthenticationLoading } from '../../core/auth/selectors'; import { CoreState } from '../../core/core.reducers'; import { AuthService } from '../../core/auth/auth.service'; +import { getForgotPasswordPath, getRegisterPath } from '../../app-routing.module'; /** * /users/sign-in @@ -82,4 +83,11 @@ export class LogInComponent implements OnInit, OnDestroy { this.alive = false; } + getRegisterPath() { + return getRegisterPath(); + } + + getForgotPath() { + return getForgotPasswordPath(); + } } diff --git a/src/app/shared/mocks/request.service.mock.ts b/src/app/shared/mocks/request.service.mock.ts index 6a3f182868..385195bc77 100644 --- a/src/app/shared/mocks/request.service.mock.ts +++ b/src/app/shared/mocks/request.service.mock.ts @@ -11,7 +11,7 @@ export function getMockRequestService(requestEntry$: Observable = getByUUID: requestEntry$, uriEncodeBody: jasmine.createSpy('uriEncodeBody'), isCachedOrPending: false, - removeByHrefSubstring: jasmine.createSpy('removeByHrefSubstring').and.returnValue(observableOf(true)), + removeByHrefSubstring: observableOf(true), hasByHrefObservable: observableOf(false) }); } diff --git a/src/app/shared/shared.module.ts b/src/app/shared/shared.module.ts index e83e1179f6..e729180e67 100644 --- a/src/app/shared/shared.module.ts +++ b/src/app/shared/shared.module.ts @@ -202,6 +202,7 @@ import { ResourcePolicyTargetResolver } from './resource-policies/resolvers/reso import { ResourcePolicyResolver } from './resource-policies/resolvers/resource-policy.resolver'; import { EpersonSearchBoxComponent } from './resource-policies/form/eperson-group-list/eperson-search-box/eperson-search-box.component'; import { GroupSearchBoxComponent } from './resource-policies/form/eperson-group-list/group-search-box/group-search-box.component'; +import { FileDownloadLinkComponent } from './file-download-link/file-download-link.component'; import { CollectionDropdownComponent } from './collection-dropdown/collection-dropdown.component'; const MODULES = [ @@ -388,6 +389,7 @@ const COMPONENTS = [ EpersonGroupListComponent, EpersonSearchBoxComponent, GroupSearchBoxComponent, + FileDownloadLinkComponent, CollectionDropdownComponent ]; @@ -462,7 +464,8 @@ const ENTRY_COMPONENTS = [ ClaimedTaskActionsRejectComponent, ClaimedTaskActionsReturnToPoolComponent, ClaimedTaskActionsEditMetadataComponent, - CollectionDropdownComponent + CollectionDropdownComponent, + FileDownloadLinkComponent ]; 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 1dc04380df..671c9237bf 100644 --- a/src/app/shared/testing/auth-request-service.stub.ts +++ b/src/app/shared/testing/auth-request-service.stub.ts @@ -9,6 +9,7 @@ import { EPersonMock } from './eperson.mock'; export class AuthRequestServiceStub { protected mockUser: EPerson = EPersonMock; protected mockTokenInfo = new AuthTokenInfo('test_token'); + protected mockShortLivedToken = 'test-shortlived-token'; public postToEndpoint(method: string, body: any, options?: HttpOptions): Observable { const authStatusStub: AuthStatus = new AuthStatus(); @@ -82,4 +83,8 @@ export class AuthRequestServiceStub { } return obj; } + + public getShortlivedToken() { + return observableOf(this.mockShortLivedToken); + } } diff --git a/src/assets/i18n/en.json5 b/src/assets/i18n/en.json5 index 1e0750efd6..cc21874672 100644 --- a/src/assets/i18n/en.json5 +++ b/src/assets/i18n/en.json5 @@ -948,6 +948,62 @@ "footer.link.duraspace": "DuraSpace", + "forgot-email.form.header": "Forgot Password", + + "forgot-email.form.info": "Enter Register an account to subscribe to collections for email updates, and submit new items to DSpace.", + + "forgot-email.form.email": "Email Address *", + + "forgot-email.form.email.error.required": "Please fill in an email address", + + "forgot-email.form.email.error.pattern": "Please fill in a valid email address", + + "forgot-email.form.email.hint": "This address will be verified and used as your login name.", + + "forgot-email.form.submit": "Submit", + + "forgot-email.form.success.head": "Verification email sent", + + "forgot-email.form.success.content": "An email has been sent to {{ email }} containing a special URL and further instructions.", + + "forgot-email.form.error.head": "Error when trying to register email", + + "forgot-email.form.error.content": "An error occured when registering the following email address: {{ email }}", + + + + "forgot-password.title": "Forgot Password", + + "forgot-password.form.head": "Forgot Password", + + "forgot-password.form.info": "Enter a new password in the box below, and confirm it by typing it again into the second box. It should be at least six characters long.", + + "forgot-password.form.card.security": "Security", + + "forgot-password.form.identification.header": "Identify", + + "forgot-password.form.identification.email": "Email address: ", + + "forgot-password.form.label.password": "Password", + + "forgot-password.form.label.passwordrepeat": "Retype to confirm", + + "forgot-password.form.error.empty-password": "Please enter a password in the box below.", + + "forgot-password.form.error.matching-passwords": "The passwords do not match.", + + "forgot-password.form.error.password-length": "The password should be at least 6 characters long.", + + "forgot-password.form.notification.error.title": "Error when trying to submit new password", + + "forgot-password.form.notification.success.content": "The password reset was successful. You have been logged in as the created user.", + + "forgot-password.form.notification.success.title": "Password reset completed", + + "forgot-password.form.submit": "Submit password", + + + "form.add": "Add", "form.add-help": "Click here to add the current entry and to add another one", @@ -2080,6 +2136,74 @@ "publication.search.title": "DSpace Angular :: Publication Search", + "register-email.title": "New user registration", + + "register-page.create-profile.header": "Create Profile", + + "register-page.create-profile.identification.header": "Identify", + + "register-page.create-profile.identification.email": "Email Address", + + "register-page.create-profile.identification.first-name": "First Name *", + + "register-page.create-profile.identification.first-name.error": "Please fill in a First Name", + + "register-page.create-profile.identification.last-name": "Last Name *", + + "register-page.create-profile.identification.last-name.error": "Please fill in a Last Name", + + "register-page.create-profile.identification.contact": "Contact Telephone", + + "register-page.create-profile.identification.language": "Language", + + "register-page.create-profile.security.header": "Security", + + "register-page.create-profile.security.info": "Please enter a password in the box below, and confirm it by typing it again into the second box. It should be at least six characters long.", + + "register-page.create-profile.security.label.password": "Password *", + + "register-page.create-profile.security.label.passwordrepeat": "Retype to confirm *", + + "register-page.create-profile.security.error.empty-password": "Please enter a password in the box below.", + + "register-page.create-profile.security.error.matching-passwords": "The passwords do not match.", + + "register-page.create-profile.security.error.password-length": "The password should be at least 6 characters long.", + + "register-page.create-profile.submit": "Complete Registration", + + "register-page.create-profile.submit.error.content": "Something went wrong while registering a new user.", + + "register-page.create-profile.submit.error.head": "Registration failed", + + "register-page.create-profile.submit.success.content": "The registration was successful. You have been logged in as the created user.", + + "register-page.create-profile.submit.success.head": "Registration completed", + + + "register-page.registration.header": "New user registration", + + "register-page.registration.info": "Register an account to subscribe to collections for email updates, and submit new items to DSpace.", + + "register-page.registration.email": "Email Address *", + + "register-page.registration.email.error.required": "Please fill in an email address", + + "register-page.registration.email.error.pattern": "Please fill in a valid email address", + + "register-page.registration.email.hint": "This address will be verified and used as your login name.", + + "register-page.registration.submit": "Register", + + "register-page.registration.success.head": "Verification email sent", + + "register-page.registration.success.content": "An email has been sent to {{ email }} containing a special URL and further instructions.", + + "register-page.registration.error.head": "Error when trying to register email", + + "register-page.registration.error.content": "An error occured when registering the following email address: {{ email }}", + + "relationships.isAuthorOf": "Authors",