mirror of
https://github.com/DSpace/dspace-angular.git
synced 2025-10-07 10:04:11 +00:00
Merge branch 'master' into language-header
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
::ng-deep {
|
:host ::ng-deep {
|
||||||
.fa-chevron-right {
|
.fa-chevron-right {
|
||||||
padding-left: $spacer/2;
|
padding-left: $spacer/2;
|
||||||
font-size: 0.5rem;
|
font-size: 0.5rem;
|
||||||
|
@@ -21,9 +21,9 @@
|
|||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-2">
|
<div class="col-2">
|
||||||
<a [href]="file._links.content.href" [download]="file.name">
|
<ds-file-download-link [href]="file._links.content.href" [download]="file.name">
|
||||||
{{"item.page.filesection.download" | translate}}
|
{{"item.page.filesection.download" | translate}}
|
||||||
</a>
|
</ds-file-download-link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ds-metadata-field-wrapper>
|
</ds-metadata-field-wrapper>
|
||||||
|
@@ -1,11 +1,11 @@
|
|||||||
<ng-container *ngVar="(bitstreams$ | async) as bitstreams">
|
<ng-container *ngVar="(bitstreams$ | async) as bitstreams">
|
||||||
<ds-metadata-field-wrapper *ngIf="bitstreams?.length > 0" [label]="label | translate">
|
<ds-metadata-field-wrapper *ngIf="bitstreams?.length > 0" [label]="label | translate">
|
||||||
<div class="file-section">
|
<div class="file-section">
|
||||||
<a *ngFor="let file of bitstreams; let last=last;" [href]="file?._links.content.href" [download]="file?.name">
|
<ds-file-download-link *ngFor="let file of bitstreams; let last=last;" [href]="file?._links.content.href" [download]="file?.name">
|
||||||
<span>{{file?.name}}</span>
|
<span>{{file?.name}}</span>
|
||||||
<span>({{(file?.sizeBytes) | dsFileSize }})</span>
|
<span>({{(file?.sizeBytes) | dsFileSize }})</span>
|
||||||
<span *ngIf="!last" innerHTML="{{separator}}"></span>
|
<span *ngIf="!last" innerHTML="{{separator}}"></span>
|
||||||
</a>
|
</ds-file-download-link>
|
||||||
</div>
|
</div>
|
||||||
</ds-metadata-field-wrapper>
|
</ds-metadata-field-wrapper>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
@@ -45,6 +45,20 @@ export function getProfileModulePath() {
|
|||||||
return `/${PROFILE_MODULE_PATH}`;
|
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';
|
const WORKFLOW_ITEM_MODULE_PATH = 'workflowitems';
|
||||||
|
|
||||||
export function getWorkflowItemModulePath() {
|
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: 'community-list', loadChildren: './community-list-page/community-list-page.module#CommunityListPageModule' },
|
||||||
{ path: 'id', loadChildren: './+lookup-by-id/lookup-by-id.module#LookupIdModule' },
|
{ path: 'id', loadChildren: './+lookup-by-id/lookup-by-id.module#LookupIdModule' },
|
||||||
{ path: 'handle', 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: COMMUNITY_MODULE_PATH, loadChildren: './+community-page/community-page.module#CommunityPageModule' },
|
||||||
{ path: COLLECTION_MODULE_PATH, loadChildren: './+collection-page/collection-page.module#CollectionPageModule' },
|
{ path: COLLECTION_MODULE_PATH, loadChildren: './+collection-page/collection-page.module#CollectionPageModule' },
|
||||||
{ path: ITEM_MODULE_PATH, loadChildren: './+item-page/item-page.module#ItemPageModule' },
|
{ path: ITEM_MODULE_PATH, loadChildren: './+item-page/item-page.module#ItemPageModule' },
|
||||||
|
@@ -1,3 +1,5 @@
|
|||||||
|
import { NgZone } from '@angular/core';
|
||||||
|
import { FindListOptions } from '../core/data/request.models';
|
||||||
import { CommunityListService, FlatNode } from './community-list-service';
|
import { CommunityListService, FlatNode } from './community-list-service';
|
||||||
import { CollectionViewer, DataSource } from '@angular/cdk/typings/collections';
|
import { CollectionViewer, DataSource } from '@angular/cdk/typings/collections';
|
||||||
import { BehaviorSubject, Observable, } from 'rxjs';
|
import { BehaviorSubject, Observable, } from 'rxjs';
|
||||||
@@ -14,21 +16,23 @@ export class CommunityListDatasource implements DataSource<FlatNode> {
|
|||||||
private communityList$ = new BehaviorSubject<FlatNode[]>([]);
|
private communityList$ = new BehaviorSubject<FlatNode[]>([]);
|
||||||
public loading$ = new BehaviorSubject<boolean>(false);
|
public loading$ = new BehaviorSubject<boolean>(false);
|
||||||
|
|
||||||
constructor(private communityListService: CommunityListService) {
|
constructor(private communityListService: CommunityListService,
|
||||||
|
private zone: NgZone) {
|
||||||
}
|
}
|
||||||
|
|
||||||
connect(collectionViewer: CollectionViewer): Observable<FlatNode[]> {
|
connect(collectionViewer: CollectionViewer): Observable<FlatNode[]> {
|
||||||
return this.communityList$.asObservable();
|
return this.communityList$.asObservable();
|
||||||
}
|
}
|
||||||
|
|
||||||
loadCommunities(expandedNodes: FlatNode[]) {
|
loadCommunities(findOptions: FindListOptions, expandedNodes: FlatNode[]) {
|
||||||
this.loading$.next(true);
|
this.loading$.next(true);
|
||||||
|
this.zone.runOutsideAngular(() => {
|
||||||
this.communityListService.loadCommunities(expandedNodes).pipe(
|
this.communityListService.loadCommunities(findOptions, expandedNodes).pipe(
|
||||||
take(1),
|
take(1),
|
||||||
finalize(() => this.loading$.next(false)),
|
finalize(() => this.zone.run(() => this.loading$.next(false))),
|
||||||
).subscribe((flatNodes: FlatNode[]) => {
|
).subscribe((flatNodes: FlatNode[]) => {
|
||||||
this.communityList$.next(flatNodes);
|
this.zone.run(() => this.communityList$.next(flatNodes));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -1,21 +1,19 @@
|
|||||||
import { of as observableOf } from 'rxjs';
|
import { inject, TestBed } from '@angular/core/testing';
|
||||||
import { TestBed, inject, async } from '@angular/core/testing';
|
|
||||||
import { Store } from '@ngrx/store';
|
import { Store } from '@ngrx/store';
|
||||||
|
import { of as observableOf } from 'rxjs';
|
||||||
|
import { take } from 'rxjs/operators';
|
||||||
import { AppState } from '../app.reducer';
|
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 { StoreMock } from '../shared/testing/store.mock';
|
||||||
import { CommunityListService, FlatNode, toFlatNode } from './community-list-service';
|
import { CommunityListService, FlatNode, toFlatNode } from './community-list-service';
|
||||||
import { CollectionDataService } from '../core/data/collection-data.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 { CommunityDataService } from '../core/data/community-data.service';
|
||||||
import {
|
|
||||||
createFailedRemoteDataObject$,
|
|
||||||
createSuccessfulRemoteDataObject$
|
|
||||||
} from '../shared/remote-data.utils';
|
|
||||||
import { Community } from '../core/shared/community.model';
|
import { Community } from '../core/shared/community.model';
|
||||||
import { Collection } from '../core/shared/collection.model';
|
import { Collection } from '../core/shared/collection.model';
|
||||||
import { take } from 'rxjs/operators';
|
|
||||||
import { FindListOptions } from '../core/data/request.models';
|
import { FindListOptions } from '../core/data/request.models';
|
||||||
|
import { PageInfo } from '../core/shared/page-info.model';
|
||||||
|
|
||||||
describe('CommunityListService', () => {
|
describe('CommunityListService', () => {
|
||||||
let store: StoreMock<AppState>;
|
let store: StoreMock<AppState>;
|
||||||
@@ -212,9 +210,11 @@ describe('CommunityListService', () => {
|
|||||||
let findTopSpy;
|
let findTopSpy;
|
||||||
beforeEach((done) => {
|
beforeEach((done) => {
|
||||||
findTopSpy = spyOn(communityDataServiceStub, 'findTop').and.callThrough();
|
findTopSpy = spyOn(communityDataServiceStub, 'findTop').and.callThrough();
|
||||||
service.getNextPageTopCommunities();
|
|
||||||
|
|
||||||
service.loadCommunities(null)
|
service.loadCommunities({
|
||||||
|
currentPage: 2,
|
||||||
|
sort: new SortOptions('dc.title', SortDirection.ASC)
|
||||||
|
}, null)
|
||||||
.pipe(take(1))
|
.pipe(take(1))
|
||||||
.subscribe((value) => {
|
.subscribe((value) => {
|
||||||
flatNodeList = value;
|
flatNodeList = value;
|
||||||
@@ -240,7 +240,10 @@ describe('CommunityListService', () => {
|
|||||||
let flatNodeList;
|
let flatNodeList;
|
||||||
describe('None expanded: should return list containing only flatnodes of the test top communities', () => {
|
describe('None expanded: should return list containing only flatnodes of the test top communities', () => {
|
||||||
beforeEach((done) => {
|
beforeEach((done) => {
|
||||||
service.loadCommunities(null)
|
service.loadCommunities({
|
||||||
|
currentPage: 1,
|
||||||
|
sort: new SortOptions('dc.title', SortDirection.ASC)
|
||||||
|
}, null)
|
||||||
.pipe(take(1))
|
.pipe(take(1))
|
||||||
.subscribe((value) => {
|
.subscribe((value) => {
|
||||||
flatNodeList = value;
|
flatNodeList = value;
|
||||||
@@ -270,7 +273,10 @@ describe('CommunityListService', () => {
|
|||||||
communityFlatNode.currentCommunityPage = 1;
|
communityFlatNode.currentCommunityPage = 1;
|
||||||
expandedNodes.push(communityFlatNode);
|
expandedNodes.push(communityFlatNode);
|
||||||
});
|
});
|
||||||
service.loadCommunities(expandedNodes)
|
service.loadCommunities({
|
||||||
|
currentPage: 1,
|
||||||
|
sort: new SortOptions('dc.title', SortDirection.ASC)
|
||||||
|
}, expandedNodes)
|
||||||
.pipe(take(1))
|
.pipe(take(1))
|
||||||
.subscribe((value) => {
|
.subscribe((value) => {
|
||||||
flatNodeList = value;
|
flatNodeList = value;
|
||||||
@@ -295,7 +301,10 @@ describe('CommunityListService', () => {
|
|||||||
communityFlatNode.currentCollectionPage = 1;
|
communityFlatNode.currentCollectionPage = 1;
|
||||||
communityFlatNode.currentCommunityPage = 1;
|
communityFlatNode.currentCommunityPage = 1;
|
||||||
const expandedNodes = [communityFlatNode];
|
const expandedNodes = [communityFlatNode];
|
||||||
service.loadCommunities(expandedNodes)
|
service.loadCommunities({
|
||||||
|
currentPage: 1,
|
||||||
|
sort: new SortOptions('dc.title', SortDirection.ASC)
|
||||||
|
}, expandedNodes)
|
||||||
.pipe(take(1))
|
.pipe(take(1))
|
||||||
.subscribe((value) => {
|
.subscribe((value) => {
|
||||||
flatNodeList = value;
|
flatNodeList = value;
|
||||||
@@ -317,7 +326,10 @@ describe('CommunityListService', () => {
|
|||||||
communityFlatNode.currentCollectionPage = 2;
|
communityFlatNode.currentCollectionPage = 2;
|
||||||
communityFlatNode.currentCommunityPage = 1;
|
communityFlatNode.currentCommunityPage = 1;
|
||||||
const expandedNodes = [communityFlatNode];
|
const expandedNodes = [communityFlatNode];
|
||||||
service.loadCommunities(expandedNodes)
|
service.loadCommunities({
|
||||||
|
currentPage: 1,
|
||||||
|
sort: new SortOptions('dc.title', SortDirection.ASC)
|
||||||
|
}, expandedNodes)
|
||||||
.pipe(take(1))
|
.pipe(take(1))
|
||||||
.subscribe((value) => {
|
.subscribe((value) => {
|
||||||
flatNodeList = value;
|
flatNodeList = value;
|
||||||
|
@@ -4,11 +4,12 @@ import { combineLatest as observableCombineLatest } from 'rxjs/internal/observab
|
|||||||
import { Observable, of as observableOf } from 'rxjs';
|
import { Observable, of as observableOf } from 'rxjs';
|
||||||
import { AppState } from '../app.reducer';
|
import { AppState } from '../app.reducer';
|
||||||
import { CommunityDataService } from '../core/data/community-data.service';
|
import { CommunityDataService } from '../core/data/community-data.service';
|
||||||
import { PaginationComponentOptions } from '../shared/pagination/pagination-component-options.model';
|
import { FindListOptions } from '../core/data/request.models';
|
||||||
import { SortDirection, SortOptions } from '../core/cache/models/sort-options.model';
|
import { map, flatMap } from 'rxjs/operators';
|
||||||
import { catchError, filter, map, switchMap, take } from 'rxjs/operators';
|
|
||||||
import { Community } from '../core/shared/community.model';
|
import { Community } from '../core/shared/community.model';
|
||||||
import { Collection } from '../core/shared/collection.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 { hasValue, isNotEmpty } from '../shared/empty.util';
|
||||||
import { RemoteData } from '../core/data/remote-data';
|
import { RemoteData } from '../core/data/remote-data';
|
||||||
import { PaginatedList } from '../core/data/paginated-list';
|
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
|
// Helper method to combine an flatten an array of observables of flatNode arrays
|
||||||
export const combineAndFlatten = (obsList: Array<Observable<FlatNode[]>>): Observable<FlatNode[]> =>
|
export const combineAndFlatten = (obsList: Array<Observable<FlatNode[]>>): Observable<FlatNode[]> =>
|
||||||
observableCombineLatest(...obsList).pipe(
|
observableCombineLatest(...obsList).pipe(
|
||||||
map((matrix: FlatNode[][]) =>
|
map((matrix: any[][]) => [].concat(...matrix))
|
||||||
matrix.reduce((combinedList, currentList: FlatNode[]) => [...combinedList, ...currentList]))
|
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -99,6 +99,8 @@ const communityListStateSelector = (state: AppState) => state.communityList;
|
|||||||
const expandedNodesSelector = createSelector(communityListStateSelector, (communityList: CommunityListState) => communityList.expandedNodes);
|
const expandedNodesSelector = createSelector(communityListStateSelector, (communityList: CommunityListState) => communityList.expandedNodes);
|
||||||
const loadingNodeSelector = createSelector(communityListStateSelector, (communityList: CommunityListState) => communityList.loadingNode);
|
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
|
* 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
|
* 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()
|
@Injectable()
|
||||||
export class CommunityListService {
|
export class CommunityListService {
|
||||||
|
|
||||||
// page-limited list of top-level communities
|
|
||||||
payloads$: Array<Observable<PaginatedList<Community>>>;
|
|
||||||
|
|
||||||
topCommunitiesConfig: PaginationComponentOptions;
|
|
||||||
topCommunitiesSortConfig: SortOptions;
|
|
||||||
|
|
||||||
maxSubCommunitiesPerPage: number;
|
|
||||||
maxCollectionsPerPage: number;
|
|
||||||
|
|
||||||
constructor(private communityDataService: CommunityDataService, private collectionDataService: CollectionDataService,
|
constructor(private communityDataService: CommunityDataService, private collectionDataService: CollectionDataService,
|
||||||
private store: Store<any>) {
|
private store: Store<any>) {
|
||||||
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 {
|
saveCommunityListStateToStore(expandedNodes: FlatNode[], loadingNode: FlatNode): void {
|
||||||
@@ -141,57 +125,46 @@ export class CommunityListService {
|
|||||||
return this.store.select(loadingNodeSelector);
|
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.
|
* 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
|
* @param expandedNodes List of expanded nodes; if a node is not expanded its subCommunities and collections need
|
||||||
* not be added to the list
|
* not be added to the list
|
||||||
*/
|
*/
|
||||||
loadCommunities(expandedNodes: FlatNode[]): Observable<FlatNode[]> {
|
loadCommunities(findOptions: FindListOptions, expandedNodes: FlatNode[]): Observable<FlatNode[]> {
|
||||||
const res = this.payloads$.map((payload) => {
|
const currentPage = findOptions.currentPage;
|
||||||
return payload.pipe(
|
const topCommunities = [];
|
||||||
take(1),
|
for (let i = 1; i <= currentPage; i++) {
|
||||||
switchMap((result: PaginatedList<Community>) => {
|
const pagination: FindListOptions = Object.assign({}, findOptions, { currentPage: i });
|
||||||
return this.transformListOfCommunities(result, 0, null, expandedNodes);
|
topCommunities.push(this.getTopCommunities(pagination));
|
||||||
}),
|
}
|
||||||
catchError(() => observableOf([])),
|
const topComs$ = observableCombineLatest(...topCommunities).pipe(
|
||||||
|
map((coms: Array<PaginatedList<Community>>) => {
|
||||||
|
const newPages: Community[][] = coms.map((unit: PaginatedList<Community>) => 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<Community>) => this.transformListOfCommunities(topComs, 0, null, expandedNodes)));
|
||||||
return combineAndFlatten(res);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Puts the initial top level communities in a list to be called upon
|
* Puts the initial top level communities in a list to be called upon
|
||||||
*/
|
*/
|
||||||
private initTopCommunityList(): void {
|
private getTopCommunities(options: FindListOptions): Observable<PaginatedList<Community>> {
|
||||||
this.payloads$ = [this.communityDataService.findTop({
|
return this.communityDataService.findTop({
|
||||||
currentPage: this.topCommunitiesConfig.currentPage,
|
currentPage: options.currentPage,
|
||||||
elementsPerPage: this.topCommunitiesConfig.pageSize,
|
elementsPerPage: MAX_COMCOLS_PER_PAGE,
|
||||||
sort: {
|
sort: {
|
||||||
field: this.topCommunitiesSortConfig.field,
|
field: options.sort.field,
|
||||||
direction: this.topCommunitiesSortConfig.direction
|
direction: options.sort.direction
|
||||||
}
|
}
|
||||||
}).pipe(
|
}).pipe(
|
||||||
take(1),
|
|
||||||
map((results) => results.payload),
|
map((results) => results.payload),
|
||||||
)];
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -206,16 +179,15 @@ export class CommunityListService {
|
|||||||
parent: FlatNode,
|
parent: FlatNode,
|
||||||
expandedNodes: FlatNode[]): Observable<FlatNode[]> {
|
expandedNodes: FlatNode[]): Observable<FlatNode[]> {
|
||||||
if (isNotEmpty(listOfPaginatedCommunities.page)) {
|
if (isNotEmpty(listOfPaginatedCommunities.page)) {
|
||||||
let currentPage = this.topCommunitiesConfig.currentPage;
|
let currentPage = listOfPaginatedCommunities.currentPage;
|
||||||
if (isNotEmpty(parent)) {
|
if (isNotEmpty(parent)) {
|
||||||
currentPage = expandedNodes.find((node: FlatNode) => node.id === parent.id).currentCommunityPage;
|
currentPage = expandedNodes.find((node: FlatNode) => node.id === parent.id).currentCommunityPage;
|
||||||
}
|
}
|
||||||
const isNotAllCommunities = (listOfPaginatedCommunities.totalElements > (listOfPaginatedCommunities.elementsPerPage * currentPage));
|
|
||||||
let obsList = listOfPaginatedCommunities.page
|
let obsList = listOfPaginatedCommunities.page
|
||||||
.map((community: Community) => {
|
.map((community: Community) => {
|
||||||
return this.transformCommunity(community, level, parent, expandedNodes)
|
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)])];
|
obsList = [...obsList, observableOf([showMoreFlatNode('community', level, parent)])];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -252,13 +224,12 @@ export class CommunityListService {
|
|||||||
let subcoms = [];
|
let subcoms = [];
|
||||||
for (let i = 1; i <= currentCommunityPage; i++) {
|
for (let i = 1; i <= currentCommunityPage; i++) {
|
||||||
const nextSetOfSubcommunitiesPage = this.communityDataService.findByParent(community.uuid, {
|
const nextSetOfSubcommunitiesPage = this.communityDataService.findByParent(community.uuid, {
|
||||||
elementsPerPage: this.maxSubCommunitiesPerPage,
|
elementsPerPage: MAX_COMCOLS_PER_PAGE,
|
||||||
currentPage: i
|
currentPage: i
|
||||||
})
|
})
|
||||||
.pipe(
|
.pipe(
|
||||||
filter((rd: RemoteData<PaginatedList<Community>>) => rd.hasSucceeded),
|
getSucceededRemoteData(),
|
||||||
take(1),
|
flatMap((rd: RemoteData<PaginatedList<Community>>) =>
|
||||||
switchMap((rd: RemoteData<PaginatedList<Community>>) =>
|
|
||||||
this.transformListOfCommunities(rd.payload, level + 1, communityFlatNode, expandedNodes))
|
this.transformListOfCommunities(rd.payload, level + 1, communityFlatNode, expandedNodes))
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -271,16 +242,15 @@ export class CommunityListService {
|
|||||||
let collections = [];
|
let collections = [];
|
||||||
for (let i = 1; i <= currentCollectionPage; i++) {
|
for (let i = 1; i <= currentCollectionPage; i++) {
|
||||||
const nextSetOfCollectionsPage = this.collectionDataService.findByParent(community.uuid, {
|
const nextSetOfCollectionsPage = this.collectionDataService.findByParent(community.uuid, {
|
||||||
elementsPerPage: this.maxCollectionsPerPage,
|
elementsPerPage: MAX_COMCOLS_PER_PAGE,
|
||||||
currentPage: i
|
currentPage: i
|
||||||
})
|
})
|
||||||
.pipe(
|
.pipe(
|
||||||
filter((rd: RemoteData<PaginatedList<Collection>>) => rd.hasSucceeded),
|
getSucceededRemoteData(),
|
||||||
take(1),
|
|
||||||
map((rd: RemoteData<PaginatedList<Collection>>) => {
|
map((rd: RemoteData<PaginatedList<Collection>>) => {
|
||||||
let nodes = rd.payload.page
|
let nodes = rd.payload.page
|
||||||
.map((collection: Collection) => toFlatNode(collection, observableOf(false), level + 1, false, communityFlatNode));
|
.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)];
|
nodes = [...nodes, showMoreFlatNode('collection', level + 1, communityFlatNode)];
|
||||||
}
|
}
|
||||||
return nodes;
|
return nodes;
|
||||||
@@ -305,21 +275,18 @@ export class CommunityListService {
|
|||||||
let hasColls$: Observable<boolean>;
|
let hasColls$: Observable<boolean>;
|
||||||
hasSubcoms$ = this.communityDataService.findByParent(community.uuid, { elementsPerPage: 1 })
|
hasSubcoms$ = this.communityDataService.findByParent(community.uuid, { elementsPerPage: 1 })
|
||||||
.pipe(
|
.pipe(
|
||||||
filter((rd: RemoteData<PaginatedList<Community>>) => rd.hasSucceeded),
|
getSucceededRemoteData(),
|
||||||
take(1),
|
|
||||||
map((results) => results.payload.totalElements > 0),
|
map((results) => results.payload.totalElements > 0),
|
||||||
);
|
);
|
||||||
|
|
||||||
hasColls$ = this.collectionDataService.findByParent(community.uuid, { elementsPerPage: 1 })
|
hasColls$ = this.collectionDataService.findByParent(community.uuid, { elementsPerPage: 1 })
|
||||||
.pipe(
|
.pipe(
|
||||||
filter((rd: RemoteData<PaginatedList<Collection>>) => rd.hasSucceeded),
|
getSucceededRemoteData(),
|
||||||
take(1),
|
|
||||||
map((results) => results.payload.totalElements > 0),
|
map((results) => results.payload.totalElements > 0),
|
||||||
);
|
);
|
||||||
|
|
||||||
let hasChildren$: Observable<boolean>;
|
let hasChildren$: Observable<boolean>;
|
||||||
hasChildren$ = observableCombineLatest(hasSubcoms$, hasColls$).pipe(
|
hasChildren$ = observableCombineLatest(hasSubcoms$, hasColls$).pipe(
|
||||||
take(1),
|
|
||||||
map(([hasSubcoms, hasColls]: [boolean, boolean]) => {
|
map(([hasSubcoms, hasColls]: [boolean, boolean]) => {
|
||||||
if (hasSubcoms || hasColls) {
|
if (hasSubcoms || hasColls) {
|
||||||
return true;
|
return true;
|
||||||
|
@@ -114,15 +114,9 @@ describe('CommunityListComponent', () => {
|
|||||||
|
|
||||||
beforeEach(async(() => {
|
beforeEach(async(() => {
|
||||||
communityListServiceStub = {
|
communityListServiceStub = {
|
||||||
topPageSize: 2,
|
pageSize: 2,
|
||||||
topCurrentPage: 1,
|
|
||||||
collectionPageSize: 2,
|
|
||||||
subcommunityPageSize: 2,
|
|
||||||
expandedNodes: [],
|
expandedNodes: [],
|
||||||
loadingNode: null,
|
loadingNode: null,
|
||||||
getNextPageTopCommunities() {
|
|
||||||
this.topCurrentPage++;
|
|
||||||
},
|
|
||||||
getLoadingNodeFromStore() {
|
getLoadingNodeFromStore() {
|
||||||
return observableOf(this.loadingNode);
|
return observableOf(this.loadingNode);
|
||||||
},
|
},
|
||||||
@@ -133,12 +127,12 @@ describe('CommunityListComponent', () => {
|
|||||||
this.expandedNodes = expandedNodes;
|
this.expandedNodes = expandedNodes;
|
||||||
this.loadingNode = loadingNode;
|
this.loadingNode = loadingNode;
|
||||||
},
|
},
|
||||||
loadCommunities(expandedNodes) {
|
loadCommunities(options, expandedNodes) {
|
||||||
let flatnodes;
|
let flatnodes;
|
||||||
let showMoreTopComNode = false;
|
let showMoreTopComNode = false;
|
||||||
flatnodes = [...mockTopFlatnodesUnexpanded];
|
flatnodes = [...mockTopFlatnodesUnexpanded];
|
||||||
const currentPage = this.topCurrentPage;
|
const currentPage = options.currentPage;
|
||||||
const elementsPerPage = this.topPageSize;
|
const elementsPerPage = this.pageSize;
|
||||||
let endPageIndex = (currentPage * elementsPerPage);
|
let endPageIndex = (currentPage * elementsPerPage);
|
||||||
if (endPageIndex >= flatnodes.length) {
|
if (endPageIndex >= flatnodes.length) {
|
||||||
endPageIndex = flatnodes.length;
|
endPageIndex = flatnodes.length;
|
||||||
@@ -171,14 +165,14 @@ describe('CommunityListComponent', () => {
|
|||||||
collFlatnodes = [...collFlatnodes, toFlatNode(coll, observableOf(false), topNode.level + 1, false, topNode)];
|
collFlatnodes = [...collFlatnodes, toFlatNode(coll, observableOf(false), topNode.level + 1, false, topNode)];
|
||||||
});
|
});
|
||||||
if (isNotEmpty(subComFlatnodes)) {
|
if (isNotEmpty(subComFlatnodes)) {
|
||||||
const endSubComIndex = this.subcommunityPageSize * expandedParent.currentCommunityPage;
|
const endSubComIndex = this.pageSize * expandedParent.currentCommunityPage;
|
||||||
flatnodes = [...flatnodes, ...subComFlatnodes.slice(0, endSubComIndex)];
|
flatnodes = [...flatnodes, ...subComFlatnodes.slice(0, endSubComIndex)];
|
||||||
if (subComFlatnodes.length > endSubComIndex) {
|
if (subComFlatnodes.length > endSubComIndex) {
|
||||||
flatnodes = [...flatnodes, showMoreFlatNode('community', topNode.level + 1, expandedParent)];
|
flatnodes = [...flatnodes, showMoreFlatNode('community', topNode.level + 1, expandedParent)];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (isNotEmpty(collFlatnodes)) {
|
if (isNotEmpty(collFlatnodes)) {
|
||||||
const endColIndex = this.collectionPageSize * expandedParent.currentCollectionPage;
|
const endColIndex = this.pageSize * expandedParent.currentCollectionPage;
|
||||||
flatnodes = [...flatnodes, ...collFlatnodes.slice(0, endColIndex)];
|
flatnodes = [...flatnodes, ...collFlatnodes.slice(0, endColIndex)];
|
||||||
if (collFlatnodes.length > endColIndex) {
|
if (collFlatnodes.length > endColIndex) {
|
||||||
flatnodes = [...flatnodes, showMoreFlatNode('collection', topNode.level + 1, expandedParent)];
|
flatnodes = [...flatnodes, showMoreFlatNode('collection', topNode.level + 1, expandedParent)];
|
||||||
|
@@ -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 { 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 { CommunityListService, FlatNode } from '../community-list-service';
|
||||||
import { CommunityListDatasource } from '../community-list-datasource';
|
import { CommunityListDatasource } from '../community-list-datasource';
|
||||||
import { FlatTreeControl } from '@angular/cdk/tree';
|
import { FlatTreeControl } from '@angular/cdk/tree';
|
||||||
@@ -27,17 +29,24 @@ export class CommunityListComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
dataSource: CommunityListDatasource;
|
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() {
|
ngOnInit() {
|
||||||
this.dataSource = new CommunityListDatasource(this.communityListService);
|
this.dataSource = new CommunityListDatasource(this.communityListService, this.zone);
|
||||||
this.communityListService.getLoadingNodeFromStore().pipe(take(1)).subscribe((result) => {
|
this.communityListService.getLoadingNodeFromStore().pipe(take(1)).subscribe((result) => {
|
||||||
this.loadingNode = result;
|
this.loadingNode = result;
|
||||||
});
|
});
|
||||||
this.communityListService.getExpandedNodesFromStore().pipe(take(1)).subscribe((result) => {
|
this.communityListService.getExpandedNodesFromStore().pipe(take(1)).subscribe((result) => {
|
||||||
this.expandedNodes = [...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;
|
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);
|
const parentNodeInExpandedNodes = this.expandedNodes.find((node2: FlatNode) => node.parent.id === node2.id);
|
||||||
parentNodeInExpandedNodes.currentCommunityPage++;
|
parentNodeInExpandedNodes.currentCommunityPage++;
|
||||||
}
|
}
|
||||||
this.dataSource.loadCommunities(this.expandedNodes);
|
this.dataSource.loadCommunities(this.paginationConfig, this.expandedNodes);
|
||||||
} else {
|
} else {
|
||||||
this.communityListService.getNextPageTopCommunities();
|
this.paginationConfig.currentPage++;
|
||||||
this.dataSource.loadCommunities(this.expandedNodes);
|
this.dataSource.loadCommunities(this.paginationConfig, this.expandedNodes);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -1,12 +1,18 @@
|
|||||||
import { Observable, of as observableOf, throwError as observableThrowError } from 'rxjs';
|
import { Observable, of as observableOf, throwError as observableThrowError } from 'rxjs';
|
||||||
import { distinctUntilChanged, filter, map, mergeMap, tap } from 'rxjs/operators';
|
import { distinctUntilChanged, filter, map, mergeMap, switchMap, tap } from 'rxjs/operators';
|
||||||
import { Inject, Injectable } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
import { HALEndpointService } from '../shared/hal-endpoint.service';
|
import { HALEndpointService } from '../shared/hal-endpoint.service';
|
||||||
import { RequestService } from '../data/request.service';
|
import { RequestService } from '../data/request.service';
|
||||||
import { GlobalConfig } from '../../../config/global-config.interface';
|
|
||||||
import { isNotEmpty } from '../../shared/empty.util';
|
import { isNotEmpty } from '../../shared/empty.util';
|
||||||
import { AuthGetRequest, AuthPostRequest, GetRequest, PostRequest, RestRequest } from '../data/request.models';
|
import {
|
||||||
import { AuthStatusResponse, ErrorResponse } from '../cache/response.models';
|
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 { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service';
|
||||||
import { getResponseFromEntry } from '../shared/operators';
|
import { getResponseFromEntry } from '../shared/operators';
|
||||||
import { HttpClient } from '@angular/common/http';
|
import { HttpClient } from '@angular/common/http';
|
||||||
@@ -15,6 +21,7 @@ import { HttpClient } from '@angular/common/http';
|
|||||||
export class AuthRequestService {
|
export class AuthRequestService {
|
||||||
protected linkName = 'authn';
|
protected linkName = 'authn';
|
||||||
protected browseEndpoint = '';
|
protected browseEndpoint = '';
|
||||||
|
protected shortlivedtokensEndpoint = 'shortlivedtokens';
|
||||||
|
|
||||||
constructor(protected halService: HALEndpointService,
|
constructor(protected halService: HALEndpointService,
|
||||||
protected requestService: RequestService,
|
protected requestService: RequestService,
|
||||||
@@ -67,4 +74,19 @@ export class AuthRequestService {
|
|||||||
mergeMap((request: GetRequest) => this.fetchRequest(request)),
|
mergeMap((request: GetRequest) => this.fetchRequest(request)),
|
||||||
distinctUntilChanged());
|
distinctUntilChanged());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a POST request to retrieve a short-lived token which provides download access of restricted files
|
||||||
|
*/
|
||||||
|
public getShortlivedToken(): Observable<string> {
|
||||||
|
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)
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -3,7 +3,6 @@ import { Action } from '@ngrx/store';
|
|||||||
// import type function
|
// import type function
|
||||||
import { type } from '../../shared/ngrx/type';
|
import { type } from '../../shared/ngrx/type';
|
||||||
// import models
|
// import models
|
||||||
import { EPerson } from '../eperson/models/eperson.model';
|
|
||||||
import { AuthTokenInfo } from './models/auth-token-info.model';
|
import { AuthTokenInfo } from './models/auth-token-info.model';
|
||||||
import { AuthMethod } from './models/auth.method';
|
import { AuthMethod } from './models/auth.method';
|
||||||
import { AuthStatus } from './models/auth-status.model';
|
import { AuthStatus } from './models/auth-status.model';
|
||||||
@@ -31,9 +30,6 @@ export const AuthActionTypes = {
|
|||||||
LOG_OUT: type('dspace/auth/LOG_OUT'),
|
LOG_OUT: type('dspace/auth/LOG_OUT'),
|
||||||
LOG_OUT_ERROR: type('dspace/auth/LOG_OUT_ERROR'),
|
LOG_OUT_ERROR: type('dspace/auth/LOG_OUT_ERROR'),
|
||||||
LOG_OUT_SUCCESS: type('dspace/auth/LOG_OUT_SUCCESS'),
|
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'),
|
SET_REDIRECT_URL: type('dspace/auth/SET_REDIRECT_URL'),
|
||||||
RETRIEVE_AUTHENTICATED_EPERSON: type('dspace/auth/RETRIEVE_AUTHENTICATED_EPERSON'),
|
RETRIEVE_AUTHENTICATED_EPERSON: type('dspace/auth/RETRIEVE_AUTHENTICATED_EPERSON'),
|
||||||
RETRIEVE_AUTHENTICATED_EPERSON_SUCCESS: type('dspace/auth/RETRIEVE_AUTHENTICATED_EPERSON_SUCCESS'),
|
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;
|
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.
|
* Add uthentication message.
|
||||||
* @class AddAuthenticationMessageAction
|
* @class AddAuthenticationMessageAction
|
||||||
@@ -439,9 +393,6 @@ export type AuthActions
|
|||||||
| CheckAuthenticationTokenCookieAction
|
| CheckAuthenticationTokenCookieAction
|
||||||
| RedirectWhenAuthenticationIsRequiredAction
|
| RedirectWhenAuthenticationIsRequiredAction
|
||||||
| RedirectWhenTokenExpiredAction
|
| RedirectWhenTokenExpiredAction
|
||||||
| RegistrationAction
|
|
||||||
| RegistrationErrorAction
|
|
||||||
| RegistrationSuccessAction
|
|
||||||
| AddAuthenticationMessageAction
|
| AddAuthenticationMessageAction
|
||||||
| RefreshTokenAction
|
| RefreshTokenAction
|
||||||
| RefreshTokenErrorAction
|
| RefreshTokenErrorAction
|
||||||
|
@@ -1,7 +1,7 @@
|
|||||||
import { Injectable } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
|
|
||||||
import { combineLatest as observableCombineLatest, Observable, of as observableOf } from 'rxjs';
|
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 @ngrx
|
||||||
import { Actions, Effect, ofType } from '@ngrx/effects';
|
import { Actions, Effect, ofType } from '@ngrx/effects';
|
||||||
import { Action, select, Store } from '@ngrx/store';
|
import { Action, select, Store } from '@ngrx/store';
|
||||||
@@ -30,9 +30,6 @@ import {
|
|||||||
RefreshTokenAction,
|
RefreshTokenAction,
|
||||||
RefreshTokenErrorAction,
|
RefreshTokenErrorAction,
|
||||||
RefreshTokenSuccessAction,
|
RefreshTokenSuccessAction,
|
||||||
RegistrationAction,
|
|
||||||
RegistrationErrorAction,
|
|
||||||
RegistrationSuccessAction,
|
|
||||||
RetrieveAuthenticatedEpersonAction,
|
RetrieveAuthenticatedEpersonAction,
|
||||||
RetrieveAuthenticatedEpersonErrorAction,
|
RetrieveAuthenticatedEpersonErrorAction,
|
||||||
RetrieveAuthenticatedEpersonSuccessAction,
|
RetrieveAuthenticatedEpersonSuccessAction,
|
||||||
@@ -136,18 +133,6 @@ export class AuthEffects {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
@Effect()
|
|
||||||
public createUser$: Observable<Action> = 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()
|
@Effect()
|
||||||
public retrieveToken$: Observable<Action> = this.actions$.pipe(
|
public retrieveToken$: Observable<Action> = this.actions$.pipe(
|
||||||
ofType(AuthActionTypes.RETRIEVE_TOKEN),
|
ofType(AuthActionTypes.RETRIEVE_TOKEN),
|
||||||
|
@@ -115,7 +115,6 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut
|
|||||||
});
|
});
|
||||||
|
|
||||||
case AuthActionTypes.AUTHENTICATE_ERROR:
|
case AuthActionTypes.AUTHENTICATE_ERROR:
|
||||||
case AuthActionTypes.REGISTRATION_ERROR:
|
|
||||||
return Object.assign({}, state, {
|
return Object.assign({}, state, {
|
||||||
authenticated: false,
|
authenticated: false,
|
||||||
authToken: undefined,
|
authToken: undefined,
|
||||||
@@ -157,18 +156,6 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut
|
|||||||
userId: undefined
|
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:
|
case AuthActionTypes.REFRESH_TOKEN:
|
||||||
return Object.assign({}, state, {
|
return Object.assign({}, state, {
|
||||||
refreshing: true,
|
refreshing: true,
|
||||||
|
@@ -1,17 +1,14 @@
|
|||||||
import { async, inject, TestBed } from '@angular/core/testing';
|
import { async, inject, TestBed } from '@angular/core/testing';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { ActivatedRoute, Router } from '@angular/router';
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
|
|
||||||
import { Store, StoreModule } from '@ngrx/store';
|
import { Store, StoreModule } from '@ngrx/store';
|
||||||
import { REQUEST } from '@nguniversal/express-engine/tokens';
|
import { REQUEST } from '@nguniversal/express-engine/tokens';
|
||||||
import { of as observableOf } from 'rxjs';
|
import { of as observableOf } from 'rxjs';
|
||||||
|
|
||||||
import { authReducer, AuthState } from './auth.reducer';
|
import { authReducer, AuthState } from './auth.reducer';
|
||||||
import { NativeWindowRef, NativeWindowService } from '../services/window.service';
|
import { NativeWindowRef, NativeWindowService } from '../services/window.service';
|
||||||
import { AuthService, IMPERSONATING_COOKIE } from './auth.service';
|
import { AuthService, IMPERSONATING_COOKIE } from './auth.service';
|
||||||
import { RouterStub } from '../../shared/testing/router.stub';
|
import { RouterStub } from '../../shared/testing/router.stub';
|
||||||
import { ActivatedRouteStub } from '../../shared/testing/active-router.stub';
|
import { ActivatedRouteStub } from '../../shared/testing/active-router.stub';
|
||||||
|
|
||||||
import { CookieService } from '../services/cookie.service';
|
import { CookieService } from '../services/cookie.service';
|
||||||
import { AuthRequestServiceStub } from '../../shared/testing/auth-request-service.stub';
|
import { AuthRequestServiceStub } from '../../shared/testing/auth-request-service.stub';
|
||||||
import { AuthRequestService } from './auth-request.service';
|
import { AuthRequestService } from './auth-request.service';
|
||||||
@@ -49,6 +46,7 @@ describe('AuthService test', () => {
|
|||||||
let storage: CookieService;
|
let storage: CookieService;
|
||||||
let token: AuthTokenInfo;
|
let token: AuthTokenInfo;
|
||||||
let authenticatedState;
|
let authenticatedState;
|
||||||
|
let unAuthenticatedState;
|
||||||
let linkService;
|
let linkService;
|
||||||
|
|
||||||
function init() {
|
function init() {
|
||||||
@@ -67,6 +65,13 @@ describe('AuthService test', () => {
|
|||||||
authToken: token,
|
authToken: token,
|
||||||
user: EPersonMock
|
user: EPersonMock
|
||||||
};
|
};
|
||||||
|
unAuthenticatedState = {
|
||||||
|
authenticated: false,
|
||||||
|
loaded: true,
|
||||||
|
loading: false,
|
||||||
|
authToken: undefined,
|
||||||
|
user: undefined
|
||||||
|
};
|
||||||
authRequest = new AuthRequestServiceStub();
|
authRequest = new AuthRequestServiceStub();
|
||||||
routeStub = new ActivatedRouteStub();
|
routeStub = new ActivatedRouteStub();
|
||||||
linkService = {
|
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', () => {
|
it('should return token object when it is valid', () => {
|
||||||
authService.hasValidAuthenticationToken().subscribe((tokenState: AuthTokenInfo) => {
|
authService.hasValidAuthenticationToken().subscribe((tokenState: AuthTokenInfo) => {
|
||||||
expect(tokenState).toBe(token);
|
expect(tokenState).toBe(token);
|
||||||
@@ -454,4 +465,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<AppState>, 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
@@ -279,18 +279,6 @@ export class AuthService {
|
|||||||
return observableOf(authMethods);
|
return observableOf(authMethods);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a new user
|
|
||||||
* @returns {User}
|
|
||||||
*/
|
|
||||||
public create(user: EPerson): Observable<EPerson> {
|
|
||||||
// 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
|
* End session
|
||||||
* @returns {Observable<boolean>}
|
* @returns {Observable<boolean>}
|
||||||
@@ -555,4 +543,14 @@ export class AuthService {
|
|||||||
return this.getImpersonateID() === epersonId;
|
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<string> {
|
||||||
|
return this.isAuthenticated().pipe(
|
||||||
|
switchMap((authenticated) => authenticated ? this.authRequestService.getShortlivedToken() : observableOf(null))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
45
src/app/core/auth/token-response-parsing.service.spec.ts
Normal file
45
src/app/core/auth/token-response-parsing.service.spec.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
23
src/app/core/auth/token-response-parsing.service.ts
Normal file
23
src/app/core/auth/token-response-parsing.service.ts
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@@ -150,12 +150,7 @@ export class RemoteDataBuildService {
|
|||||||
filterSuccessfulResponses(),
|
filterSuccessfulResponses(),
|
||||||
map((response: DSOSuccessResponse) => {
|
map((response: DSOSuccessResponse) => {
|
||||||
if (hasValue((response as DSOSuccessResponse).pageInfo)) {
|
if (hasValue((response as DSOSuccessResponse).pageInfo)) {
|
||||||
const resPageInfo = (response as DSOSuccessResponse).pageInfo;
|
return (response as DSOSuccessResponse).pageInfo;
|
||||||
if (isNotEmpty(resPageInfo) && resPageInfo.currentPage >= 0) {
|
|
||||||
return Object.assign({}, resPageInfo, { currentPage: resPageInfo.currentPage + 1 });
|
|
||||||
} else {
|
|
||||||
return resPageInfo;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
28
src/app/core/cache/response.models.ts
vendored
28
src/app/core/cache/response.models.ts
vendored
@@ -12,6 +12,7 @@ import { DSpaceObject } from '../shared/dspace-object.model';
|
|||||||
import { MetadataSchema } from '../metadata/metadata-schema.model';
|
import { MetadataSchema } from '../metadata/metadata-schema.model';
|
||||||
import { MetadataField } from '../metadata/metadata-field.model';
|
import { MetadataField } from '../metadata/metadata-field.model';
|
||||||
import { ContentSource } from '../shared/content-source.model';
|
import { ContentSource } from '../shared/content-source.model';
|
||||||
|
import { Registration } from '../shared/registration.model';
|
||||||
|
|
||||||
/* tslint:disable:max-classes-per-file */
|
/* tslint:disable:max-classes-per-file */
|
||||||
export class RestResponse {
|
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 {
|
export class IntegrationSuccessResponse extends RestResponse {
|
||||||
constructor(
|
constructor(
|
||||||
public dataDefinition: PaginatedList<IntegrationModel>,
|
public dataDefinition: PaginatedList<IntegrationModel>,
|
||||||
@@ -257,4 +272,17 @@ export class ContentSourceSuccessResponse extends RestResponse {
|
|||||||
super(true, statusCode, statusText);
|
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 */
|
/* tslint:enable:max-classes-per-file */
|
||||||
|
@@ -141,8 +141,10 @@ import { VersionHistory } from './shared/version-history.model';
|
|||||||
import { WorkflowActionDataService } from './data/workflow-action-data.service';
|
import { WorkflowActionDataService } from './data/workflow-action-data.service';
|
||||||
import { WorkflowAction } from './tasks/models/workflow-action-object.model';
|
import { WorkflowAction } from './tasks/models/workflow-action-object.model';
|
||||||
import { LocaleInterceptor } from './locale/locale.interceptor';
|
import { LocaleInterceptor } from './locale/locale.interceptor';
|
||||||
|
import { Registration } from './shared/registration.model';
|
||||||
import { MetadataSchemaDataService } from './data/metadata-schema-data.service';
|
import { MetadataSchemaDataService } from './data/metadata-schema-data.service';
|
||||||
import { MetadataFieldDataService } from './data/metadata-field-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
|
* When not in production, endpoint responses can be mocked for testing purposes
|
||||||
@@ -259,6 +261,7 @@ const PROVIDERS = [
|
|||||||
WorkflowActionDataService,
|
WorkflowActionDataService,
|
||||||
MetadataSchemaDataService,
|
MetadataSchemaDataService,
|
||||||
MetadataFieldDataService,
|
MetadataFieldDataService,
|
||||||
|
TokenResponseParsingService,
|
||||||
// register AuthInterceptor as HttpInterceptor
|
// register AuthInterceptor as HttpInterceptor
|
||||||
{
|
{
|
||||||
provide: HTTP_INTERCEPTORS,
|
provide: HTTP_INTERCEPTORS,
|
||||||
@@ -315,7 +318,8 @@ export const models =
|
|||||||
ExternalSourceEntry,
|
ExternalSourceEntry,
|
||||||
Version,
|
Version,
|
||||||
VersionHistory,
|
VersionHistory,
|
||||||
WorkflowAction
|
WorkflowAction,
|
||||||
|
Registration
|
||||||
];
|
];
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
|
89
src/app/core/data/eperson-registration.service.spec.ts
Normal file
89
src/app/core/data/eperson-registration.service.spec.ts
Normal file
@@ -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
|
||||||
|
})
|
||||||
|
}));
|
||||||
|
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
108
src/app/core/data/eperson-registration.service.ts
Normal file
108
src/app/core/data/eperson-registration.service.ts
Normal file
@@ -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<string> {
|
||||||
|
return this.halService.getEndpoint(this.linkPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves the endpoint to search by registration token
|
||||||
|
*/
|
||||||
|
getTokenSearchEndpoint(token: string): Observable<string> {
|
||||||
|
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<Registration> {
|
||||||
|
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<ResponseParsingService> {
|
||||||
|
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),
|
||||||
|
);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@@ -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'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
24
src/app/core/data/registration-response-parsing.service.ts
Normal file
24
src/app/core/data/registration-response-parsing.service.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@@ -18,6 +18,7 @@ import { URLCombiner } from '../url-combiner/url-combiner';
|
|||||||
import { TaskResponseParsingService } from '../tasks/task-response-parsing.service';
|
import { TaskResponseParsingService } from '../tasks/task-response-parsing.service';
|
||||||
import { ContentSourceResponseParsingService } from './content-source-response-parsing.service';
|
import { ContentSourceResponseParsingService } from './content-source-response-parsing.service';
|
||||||
import { MappedCollectionsReponseParsingService } from './mapped-collections-reponse-parsing.service';
|
import { MappedCollectionsReponseParsingService } from './mapped-collections-reponse-parsing.service';
|
||||||
|
import { TokenResponseParsingService } from '../auth/token-response-parsing.service';
|
||||||
|
|
||||||
/* tslint:disable:max-classes-per-file */
|
/* 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<ResponseParsingService> {
|
||||||
|
return TokenResponseParsingService;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class IntegrationRequest extends GetRequest {
|
export class IntegrationRequest extends GetRequest {
|
||||||
constructor(uuid: string, href: string) {
|
constructor(uuid: string, href: string) {
|
||||||
super(uuid, href);
|
super(uuid, href);
|
||||||
|
@@ -14,7 +14,7 @@ import { CoreState } from '../core.reducers';
|
|||||||
import { ChangeAnalyzer } from '../data/change-analyzer';
|
import { ChangeAnalyzer } from '../data/change-analyzer';
|
||||||
import { PaginatedList } from '../data/paginated-list';
|
import { PaginatedList } from '../data/paginated-list';
|
||||||
import { RemoteData } from '../data/remote-data';
|
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 { RequestEntry } from '../data/request.reducer';
|
||||||
import { RequestService } from '../data/request.service';
|
import { RequestService } from '../data/request.service';
|
||||||
import { HALEndpointService } from '../shared/hal-endpoint.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<RemoteData<any>> {
|
function getRemotedataObservable(obj: any): Observable<RemoteData<any>> {
|
||||||
|
@@ -3,7 +3,7 @@ import { Injectable } from '@angular/core';
|
|||||||
import { createSelector, select, Store } from '@ngrx/store';
|
import { createSelector, select, Store } from '@ngrx/store';
|
||||||
import { Operation } from 'fast-json-patch/lib/core';
|
import { Operation } from 'fast-json-patch/lib/core';
|
||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
import { filter, map, take } from 'rxjs/operators';
|
import { filter, find, map, take } from 'rxjs/operators';
|
||||||
import {
|
import {
|
||||||
EPeopleRegistryCancelEPersonAction,
|
EPeopleRegistryCancelEPersonAction,
|
||||||
EPeopleRegistryEditEPersonAction
|
EPeopleRegistryEditEPersonAction
|
||||||
@@ -22,12 +22,13 @@ import { DataService } from '../data/data.service';
|
|||||||
import { DSOChangeAnalyzer } from '../data/dso-change-analyzer.service';
|
import { DSOChangeAnalyzer } from '../data/dso-change-analyzer.service';
|
||||||
import { PaginatedList } from '../data/paginated-list';
|
import { PaginatedList } from '../data/paginated-list';
|
||||||
import { RemoteData } from '../data/remote-data';
|
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 { RequestService } from '../data/request.service';
|
||||||
import { HALEndpointService } from '../shared/hal-endpoint.service';
|
import { HALEndpointService } from '../shared/hal-endpoint.service';
|
||||||
import { getRemoteDataPayload, getSucceededRemoteData } from '../shared/operators';
|
import { getRemoteDataPayload, getSucceededRemoteData } from '../shared/operators';
|
||||||
import { EPerson } from './models/eperson.model';
|
import { EPerson } from './models/eperson.model';
|
||||||
import { EPERSON } from './models/eperson.resource-type';
|
import { EPERSON } from './models/eperson.resource-type';
|
||||||
|
import { RequestEntry } from '../data/request.reducer';
|
||||||
|
|
||||||
const ePeopleRegistryStateSelector = (state: AppState) => state.epeopleRegistry;
|
const ePeopleRegistryStateSelector = (state: AppState) => state.epeopleRegistry;
|
||||||
const editEPersonSelector = createSelector(ePeopleRegistryStateSelector, (ePeopleRegistryState: EPeopleRegistryState) => ePeopleRegistryState.editEPerson);
|
const editEPersonSelector = createSelector(ePeopleRegistryStateSelector, (ePeopleRegistryState: EPeopleRegistryState) => ePeopleRegistryState.editEPerson);
|
||||||
@@ -165,17 +166,17 @@ export class EPersonDataService extends DataService<EPerson> {
|
|||||||
if (hasValue(oldEPerson.email) && oldEPerson.email !== newEPerson.email) {
|
if (hasValue(oldEPerson.email) && oldEPerson.email !== newEPerson.email) {
|
||||||
operations = [...operations, {
|
operations = [...operations, {
|
||||||
op: 'replace', path: '/email', value: newEPerson.email
|
op: 'replace', path: '/email', value: newEPerson.email
|
||||||
}]
|
}];
|
||||||
}
|
}
|
||||||
if (hasValue(oldEPerson.requireCertificate) && oldEPerson.requireCertificate !== newEPerson.requireCertificate) {
|
if (hasValue(oldEPerson.requireCertificate) && oldEPerson.requireCertificate !== newEPerson.requireCertificate) {
|
||||||
operations = [...operations, {
|
operations = [...operations, {
|
||||||
op: 'replace', path: '/certificate', value: newEPerson.requireCertificate
|
op: 'replace', path: '/certificate', value: newEPerson.requireCertificate
|
||||||
}]
|
}];
|
||||||
}
|
}
|
||||||
if (hasValue(oldEPerson.canLogIn) && oldEPerson.canLogIn !== newEPerson.canLogIn) {
|
if (hasValue(oldEPerson.canLogIn) && oldEPerson.canLogIn !== newEPerson.canLogIn) {
|
||||||
operations = [...operations, {
|
operations = [...operations, {
|
||||||
op: 'replace', path: '/canLogIn', value: newEPerson.canLogIn
|
op: 'replace', path: '/canLogIn', value: newEPerson.canLogIn
|
||||||
}]
|
}];
|
||||||
}
|
}
|
||||||
return operations;
|
return operations;
|
||||||
}
|
}
|
||||||
@@ -200,7 +201,7 @@ export class EPersonDataService extends DataService<EPerson> {
|
|||||||
* Method to retrieve the eperson that is currently being edited
|
* Method to retrieve the eperson that is currently being edited
|
||||||
*/
|
*/
|
||||||
public getActiveEPerson(): Observable<EPerson> {
|
public getActiveEPerson(): Observable<EPerson> {
|
||||||
return this.store.pipe(select(editEPersonSelector))
|
return this.store.pipe(select(editEPersonSelector));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -249,4 +250,54 @@ export class EPersonDataService extends DataService<EPerson> {
|
|||||||
return '/admin/access-control/epeople';
|
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<RestResponse> {
|
||||||
|
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)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@@ -57,6 +57,12 @@ export class EPerson extends DSpaceObject {
|
|||||||
@autoserialize
|
@autoserialize
|
||||||
public selfRegistered: boolean;
|
public selfRegistered: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The password of this EPerson
|
||||||
|
*/
|
||||||
|
@autoserialize
|
||||||
|
public password: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Getter to retrieve the EPerson's full name as a string
|
* Getter to retrieve the EPerson's full name as a string
|
||||||
*/
|
*/
|
||||||
|
@@ -1,10 +1,10 @@
|
|||||||
import { Injectable } from '@angular/core';
|
import { Inject, 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 { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model';
|
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.
|
* Provides utility methods to save files on the client-side.
|
||||||
@@ -12,21 +12,19 @@ import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class FileService {
|
export class FileService {
|
||||||
constructor(
|
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
|
* @param url
|
||||||
* file url
|
* file url
|
||||||
*/
|
*/
|
||||||
downloadFile(url: string) {
|
downloadFile(url: string) {
|
||||||
const headers = new HttpHeaders();
|
this.authService.getShortlivedToken().pipe(take(1)).subscribe((token) => {
|
||||||
const options: HttpOptions = Object.create({headers, responseType: 'blob'});
|
this._window.nativeWindow.location.href = hasValue(token) ? new URLCombiner(url, `?authentication-token=${token}`).toString() : url;
|
||||||
return this.restService.request(RestRequestMethod.GET, url, null, options)
|
|
||||||
.subscribe((data) => {
|
|
||||||
saveAs(data.payload as Blob, this.getFileNameFromResponseContentDisposition(data));
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
26
src/app/core/shared/registration.model.ts
Normal file
26
src/app/core/shared/registration.model.ts
Normal file
@@ -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;
|
||||||
|
|
||||||
|
}
|
@@ -0,0 +1,3 @@
|
|||||||
|
<ds-register-email-form
|
||||||
|
[MESSAGE_PREFIX]="'forgot-email.form'">
|
||||||
|
</ds-register-email-form>
|
@@ -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<ForgotEmailComponent>;
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
@@ -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 {
|
||||||
|
|
||||||
|
}
|
@@ -0,0 +1,36 @@
|
|||||||
|
<div class="container">
|
||||||
|
<h3 class="mb-4">{{'forgot-password.form.head' | translate}}</h3>
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">{{'forgot-password.form.identification.header' | translate}}</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<label class="font-weight-bold"
|
||||||
|
for="email">{{'forgot-password.form.identification.email' | translate}}</label>
|
||||||
|
<span id="email">{{(registration$ |async).email}}</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">{{'forgot-password.form.card.security' | translate}}</div>
|
||||||
|
<div class="card-body">
|
||||||
|
|
||||||
|
<ds-profile-page-security-form
|
||||||
|
[passwordCanBeEmpty]="false"
|
||||||
|
[FORM_PREFIX]="'forgot-password.form.'"
|
||||||
|
(isInvalid)="setInValid($event)"
|
||||||
|
(passwordValue)="setPasswordValue($event)"
|
||||||
|
></ds-profile-page-security-form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<button
|
||||||
|
[disabled]="isInValid"
|
||||||
|
class="btn btn-default btn-primary"
|
||||||
|
(click)="submit()">{{'forgot-password.form.submit' | translate}}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
@@ -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<ForgotPasswordFormComponent>;
|
||||||
|
|
||||||
|
let router;
|
||||||
|
let route;
|
||||||
|
let ePersonDataService: EPersonDataService;
|
||||||
|
let notificationsService;
|
||||||
|
let store: Store<CoreState>;
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
})
|
||||||
|
});
|
@@ -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<Registration>;
|
||||||
|
|
||||||
|
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<CoreState>,
|
||||||
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
32
src/app/forgot-password/forgot-password-routing.module.ts
Normal file
32
src/app/forgot-password/forgot-password-routing.module.ts
Normal file
@@ -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 {
|
||||||
|
}
|
31
src/app/forgot-password/forgot-password.module.ts
Normal file
31
src/app/forgot-password/forgot-password.module.ts
Normal file
@@ -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 {
|
||||||
|
|
||||||
|
}
|
@@ -1,9 +1,10 @@
|
|||||||
<div class="container-fluid mb-4">{{'profile.security.form.info' | translate}}</div>
|
<div class="container-fluid mb-4">{{FORM_PREFIX + 'info' | translate}}</div>
|
||||||
<ds-form *ngIf="formModel"
|
<ds-form *ngIf="formModel"
|
||||||
[formId]="'profile-page-security-form-id'"
|
[formId]="FORM_PREFIX"
|
||||||
[formModel]="formModel"
|
[formModel]="formModel"
|
||||||
[formGroup]="formGroup"
|
[formGroup]="formGroup"
|
||||||
[displaySubmit]="false">
|
[displaySubmit]="false">
|
||||||
</ds-form>
|
</ds-form>
|
||||||
<div class="container-fluid text-danger" *ngIf="formGroup.hasError('notLongEnough')">{{'profile.security.form.error.password-length' | translate}}</div>
|
<div id="notLongEnough" class="container-fluid text-danger" *ngIf="formGroup.hasError('notLongEnough')">{{FORM_PREFIX + 'error.password-length' | translate}}</div>
|
||||||
<div class="container-fluid text-danger" *ngIf="formGroup.hasError('notSame')">{{'profile.security.form.error.matching-passwords' | translate}}</div>
|
<div id="notSame" class="container-fluid text-danger" *ngIf="formGroup.hasError('notSame')">{{FORM_PREFIX + 'error.matching-passwords' | translate}}</div>
|
||||||
|
<div id="emptyPassword" class="container-fluid text-danger" *ngIf="(formGroup.dirty || formGroup.touched) && formGroup.hasError('emptyPassword')">{{FORM_PREFIX + 'error.empty-password' | translate}}</div>
|
||||||
|
@@ -1,5 +1,4 @@
|
|||||||
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
import { async, ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing';
|
||||||
import { EPerson } from '../../core/eperson/models/eperson.model';
|
|
||||||
import { NO_ERRORS_SCHEMA } from '@angular/core';
|
import { NO_ERRORS_SCHEMA } from '@angular/core';
|
||||||
import { VarDirective } from '../../shared/utils/var.directive';
|
import { VarDirective } from '../../shared/utils/var.directive';
|
||||||
import { TranslateModule } from '@ngx-translate/core';
|
import { TranslateModule } from '@ngx-translate/core';
|
||||||
@@ -15,18 +14,10 @@ describe('ProfilePageSecurityFormComponent', () => {
|
|||||||
let component: ProfilePageSecurityFormComponent;
|
let component: ProfilePageSecurityFormComponent;
|
||||||
let fixture: ComponentFixture<ProfilePageSecurityFormComponent>;
|
let fixture: ComponentFixture<ProfilePageSecurityFormComponent>;
|
||||||
|
|
||||||
let user;
|
|
||||||
|
|
||||||
let epersonService;
|
let epersonService;
|
||||||
let notificationsService;
|
let notificationsService;
|
||||||
|
|
||||||
function init() {
|
function init() {
|
||||||
user = Object.assign(new EPerson(), {
|
|
||||||
_links: {
|
|
||||||
self: { href: 'user-selflink' }
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
epersonService = jasmine.createSpyObj('epersonService', {
|
epersonService = jasmine.createSpyObj('epersonService', {
|
||||||
patch: observableOf(new RestResponse(true, 200, 'OK'))
|
patch: observableOf(new RestResponse(true, 200, 'OK'))
|
||||||
});
|
});
|
||||||
@@ -54,65 +45,35 @@ describe('ProfilePageSecurityFormComponent', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
fixture = TestBed.createComponent(ProfilePageSecurityFormComponent);
|
fixture = TestBed.createComponent(ProfilePageSecurityFormComponent);
|
||||||
component = fixture.componentInstance;
|
component = fixture.componentInstance;
|
||||||
component.user = user;
|
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('updateSecurity', () => {
|
describe('On value change', () => {
|
||||||
describe('when no values changed', () => {
|
describe('when the password has changed', () => {
|
||||||
let result;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
result = component.updateSecurity();
|
component.formGroup.patchValue({password: 'password'});
|
||||||
|
component.formGroup.patchValue({passwordrepeat: 'password'});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return false', () => {
|
it('should emit the value and validity on password change with invalid validity', fakeAsync(() => {
|
||||||
expect(result).toEqual(false);
|
spyOn(component.passwordValue, 'emit');
|
||||||
});
|
spyOn(component.isInvalid, 'emit');
|
||||||
|
component.formGroup.patchValue({password: 'new-password'});
|
||||||
|
|
||||||
it('should not call epersonService.patch', () => {
|
tick(300);
|
||||||
expect(epersonService.patch).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('when password is filled in, but the confirm field is empty', () => {
|
expect(component.passwordValue.emit).toHaveBeenCalledWith('new-password');
|
||||||
let result;
|
expect(component.isInvalid.emit).toHaveBeenCalledWith(true);
|
||||||
|
}));
|
||||||
|
|
||||||
beforeEach(() => {
|
it('should emit the value on password change', fakeAsync(() => {
|
||||||
setModelValue('password', 'test');
|
spyOn(component.passwordValue, 'emit');
|
||||||
result = component.updateSecurity();
|
component.formGroup.patchValue({password: 'new-password'});
|
||||||
});
|
|
||||||
|
|
||||||
it('should return true', () => {
|
tick(300);
|
||||||
expect(result).toEqual(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('when both password fields are filled in, long enough and equal', () => {
|
expect(component.passwordValue.emit).toHaveBeenCalledWith('new-password');
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function setModelValue(id: string, value: string) {
|
|
||||||
component.formGroup.patchValue({
|
|
||||||
[id]: value
|
|
||||||
});
|
|
||||||
component.formGroup.markAllAsTouched();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
@@ -1,16 +1,12 @@
|
|||||||
import { Component, Input, OnInit } from '@angular/core';
|
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
|
||||||
import {
|
import { DynamicFormControlModel, DynamicFormService, DynamicInputModel } from '@ng-dynamic-forms/core';
|
||||||
DynamicFormControlModel,
|
|
||||||
DynamicFormService,
|
|
||||||
DynamicInputModel
|
|
||||||
} from '@ng-dynamic-forms/core';
|
|
||||||
import { TranslateService } from '@ngx-translate/core';
|
import { TranslateService } from '@ngx-translate/core';
|
||||||
import { FormGroup } from '@angular/forms';
|
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 { 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 { NotificationsService } from '../../shared/notifications/notifications.service';
|
||||||
|
import { debounceTime, map } from 'rxjs/operators';
|
||||||
|
import { Subscription } from 'rxjs';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'ds-profile-page-security-form',
|
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
|
* Displays a form containing a password field and a confirmation of the password
|
||||||
*/
|
*/
|
||||||
export class ProfilePageSecurityFormComponent implements OnInit {
|
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<boolean>();
|
||||||
|
/**
|
||||||
|
* Emits the value of the password
|
||||||
|
*/
|
||||||
|
@Output() passwordValue = new EventEmitter<string>();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The form's input models
|
* The form's input models
|
||||||
@@ -48,14 +49,17 @@ export class ProfilePageSecurityFormComponent implements OnInit {
|
|||||||
formGroup: FormGroup;
|
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
|
* 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,
|
constructor(protected formService: DynamicFormService,
|
||||||
protected translate: TranslateService,
|
protected translate: TranslateService,
|
||||||
@@ -64,12 +68,35 @@ export class ProfilePageSecurityFormComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ngOnInit(): void {
|
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.updateFieldTranslations();
|
||||||
this.translate.onLangChange
|
this.translate.onLangChange
|
||||||
.subscribe(() => {
|
.subscribe(() => {
|
||||||
this.updateFieldTranslations();
|
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() {
|
updateFieldTranslations() {
|
||||||
this.formModel.forEach(
|
this.formModel.forEach(
|
||||||
(fieldModel: DynamicInputModel) => {
|
(fieldModel: DynamicInputModel) => {
|
||||||
fieldModel.label = this.translate.instant(this.LABEL_PREFIX + fieldModel.id);
|
fieldModel.label = this.translate.instant(this.FORM_PREFIX + 'label.' + fieldModel.id);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -105,47 +132,20 @@ export class ProfilePageSecurityFormComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update the user's security details
|
* Checks if the password is empty
|
||||||
*
|
* @param group The FormGroup to validate
|
||||||
* 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
|
|
||||||
*/
|
*/
|
||||||
updateSecurity() {
|
checkPasswordEmpty(group: FormGroup) {
|
||||||
const pass = this.formGroup.get('password').value;
|
const pass = group.get('password').value;
|
||||||
const passEntered = isNotEmpty(pass);
|
return isEmpty(pass) ? {emptyPassword: true} : null;
|
||||||
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
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return passEntered;
|
/**
|
||||||
|
* Unsubscribe from all subscriptions
|
||||||
|
*/
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.subs
|
||||||
|
.filter((sub) => hasValue(sub))
|
||||||
|
.forEach((sub) => sub.unsubscribe());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -10,7 +10,11 @@
|
|||||||
<div class="card mb-4">
|
<div class="card mb-4">
|
||||||
<div class="card-header">{{'profile.card.security' | translate}}</div>
|
<div class="card-header">{{'profile.card.security' | translate}}</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<ds-profile-page-security-form [user]="user"></ds-profile-page-security-form>
|
<ds-profile-page-security-form
|
||||||
|
[FORM_PREFIX]="'profile.security.form.'"
|
||||||
|
(isInvalid)="setInvalid($event)"
|
||||||
|
(passwordValue)="setPasswordValue($event)"
|
||||||
|
></ds-profile-page-security-form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-outline-primary" (click)="updateProfile()">{{'profile.form.submit' | translate}}</button>
|
<button class="btn btn-outline-primary" (click)="updateProfile()">{{'profile.form.submit' | translate}}</button>
|
||||||
|
@@ -13,8 +13,9 @@ import { NotificationsService } from '../shared/notifications/notifications.serv
|
|||||||
import { authReducer } from '../core/auth/auth.reducer';
|
import { authReducer } from '../core/auth/auth.reducer';
|
||||||
import { createSuccessfulRemoteDataObject$ } from '../shared/remote-data.utils';
|
import { createSuccessfulRemoteDataObject$ } from '../shared/remote-data.utils';
|
||||||
import { createPaginatedList } from '../shared/testing/utils.test';
|
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 { AuthService } from '../core/auth/auth.service';
|
||||||
|
import { RestResponse } from '../core/cache/response.models';
|
||||||
|
|
||||||
describe('ProfilePageComponent', () => {
|
describe('ProfilePageComponent', () => {
|
||||||
let component: ProfilePageComponent;
|
let component: ProfilePageComponent;
|
||||||
@@ -40,10 +41,11 @@ describe('ProfilePageComponent', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
authService = jasmine.createSpyObj('authService', {
|
authService = jasmine.createSpyObj('authService', {
|
||||||
getAuthenticatedUserFromStore: of(user)
|
getAuthenticatedUserFromStore: observableOf(user)
|
||||||
});
|
});
|
||||||
epersonService = jasmine.createSpyObj('epersonService', {
|
epersonService = jasmine.createSpyObj('epersonService', {
|
||||||
findById: createSuccessfulRemoteDataObject$(user)
|
findById: createSuccessfulRemoteDataObject$(user),
|
||||||
|
patch: observableOf(Object.assign(new RestResponse(true, 200, 'Success')))
|
||||||
});
|
});
|
||||||
notificationsService = jasmine.createSpyObj('notificationsService', {
|
notificationsService = jasmine.createSpyObj('notificationsService', {
|
||||||
success: {},
|
success: {},
|
||||||
@@ -84,9 +86,7 @@ describe('ProfilePageComponent', () => {
|
|||||||
component.metadataForm = jasmine.createSpyObj('metadataForm', {
|
component.metadataForm = jasmine.createSpyObj('metadataForm', {
|
||||||
updateProfile: false
|
updateProfile: false
|
||||||
});
|
});
|
||||||
component.securityForm = jasmine.createSpyObj('securityForm', {
|
spyOn(component, 'updateSecurity').and.returnValue(true);
|
||||||
updateSecurity: true
|
|
||||||
});
|
|
||||||
component.updateProfile();
|
component.updateProfile();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -100,9 +100,6 @@ describe('ProfilePageComponent', () => {
|
|||||||
component.metadataForm = jasmine.createSpyObj('metadataForm', {
|
component.metadataForm = jasmine.createSpyObj('metadataForm', {
|
||||||
updateProfile: true
|
updateProfile: true
|
||||||
});
|
});
|
||||||
component.securityForm = jasmine.createSpyObj('securityForm', {
|
|
||||||
updateSecurity: false
|
|
||||||
});
|
|
||||||
component.updateProfile();
|
component.updateProfile();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -116,9 +113,6 @@ describe('ProfilePageComponent', () => {
|
|||||||
component.metadataForm = jasmine.createSpyObj('metadataForm', {
|
component.metadataForm = jasmine.createSpyObj('metadataForm', {
|
||||||
updateProfile: true
|
updateProfile: true
|
||||||
});
|
});
|
||||||
component.securityForm = jasmine.createSpyObj('securityForm', {
|
|
||||||
updateSecurity: true
|
|
||||||
});
|
|
||||||
component.updateProfile();
|
component.updateProfile();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -132,9 +126,6 @@ describe('ProfilePageComponent', () => {
|
|||||||
component.metadataForm = jasmine.createSpyObj('metadataForm', {
|
component.metadataForm = jasmine.createSpyObj('metadataForm', {
|
||||||
updateProfile: false
|
updateProfile: false
|
||||||
});
|
});
|
||||||
component.securityForm = jasmine.createSpyObj('securityForm', {
|
|
||||||
updateSecurity: false
|
|
||||||
});
|
|
||||||
component.updateProfile();
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
@@ -2,7 +2,6 @@ import { Component, OnInit, ViewChild } from '@angular/core';
|
|||||||
import { Observable } from 'rxjs/internal/Observable';
|
import { Observable } from 'rxjs/internal/Observable';
|
||||||
import { EPerson } from '../core/eperson/models/eperson.model';
|
import { EPerson } from '../core/eperson/models/eperson.model';
|
||||||
import { ProfilePageMetadataFormComponent } from './profile-page-metadata-form/profile-page-metadata-form.component';
|
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 { NotificationsService } from '../shared/notifications/notifications.service';
|
||||||
import { TranslateService } from '@ngx-translate/core';
|
import { TranslateService } from '@ngx-translate/core';
|
||||||
import { Group } from '../core/eperson/models/group.model';
|
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 { filter, switchMap, tap } from 'rxjs/operators';
|
||||||
import { EPersonDataService } from '../core/eperson/eperson-data.service';
|
import { EPersonDataService } from '../core/eperson/eperson-data.service';
|
||||||
import { getAllSucceededRemoteData, getRemoteDataPayload } from '../core/shared/operators';
|
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 { followLink } from '../shared/utils/follow-link-config.model';
|
||||||
import { AuthService } from '../core/auth/auth.service';
|
import { AuthService } from '../core/auth/auth.service';
|
||||||
|
import { ErrorResponse, RestResponse } from '../core/cache/response.models';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'ds-profile-page',
|
selector: 'ds-profile-page',
|
||||||
@@ -29,12 +29,7 @@ export class ProfilePageComponent implements OnInit {
|
|||||||
@ViewChild(ProfilePageMetadataFormComponent, {static: false}) metadataForm: ProfilePageMetadataFormComponent;
|
@ViewChild(ProfilePageMetadataFormComponent, {static: false}) metadataForm: ProfilePageMetadataFormComponent;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A reference to the security form component
|
* The authenticated user as observable
|
||||||
*/
|
|
||||||
@ViewChild(ProfilePageSecurityFormComponent, { static: false }) securityForm: ProfilePageSecurityFormComponent;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The authenticated user
|
|
||||||
*/
|
*/
|
||||||
user$: Observable<EPerson>;
|
user$: Observable<EPerson>;
|
||||||
|
|
||||||
@@ -48,6 +43,26 @@ export class ProfilePageComponent implements OnInit {
|
|||||||
*/
|
*/
|
||||||
NOTIFICATIONS_PREFIX = 'profile.notifications.';
|
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,
|
constructor(private authService: AuthService,
|
||||||
private notificationsService: NotificationsService,
|
private notificationsService: NotificationsService,
|
||||||
private translate: TranslateService,
|
private translate: TranslateService,
|
||||||
@@ -59,7 +74,8 @@ export class ProfilePageComponent implements OnInit {
|
|||||||
filter((user: EPerson) => hasValue(user.id)),
|
filter((user: EPerson) => hasValue(user.id)),
|
||||||
switchMap((user: EPerson) => this.epersonService.findById(user.id, followLink('groups'))),
|
switchMap((user: EPerson) => this.epersonService.findById(user.id, followLink('groups'))),
|
||||||
getAllSucceededRemoteData(),
|
getAllSucceededRemoteData(),
|
||||||
getRemoteDataPayload()
|
getRemoteDataPayload(),
|
||||||
|
tap((user: EPerson) => this.currentUser = user)
|
||||||
);
|
);
|
||||||
this.groupsRD$ = this.user$.pipe(switchMap((user: EPerson) => user.groups));
|
this.groupsRD$ = this.user$.pipe(switchMap((user: EPerson) => user.groups));
|
||||||
}
|
}
|
||||||
@@ -70,7 +86,7 @@ export class ProfilePageComponent implements OnInit {
|
|||||||
*/
|
*/
|
||||||
updateProfile() {
|
updateProfile() {
|
||||||
const metadataChanged = this.metadataForm.updateProfile();
|
const metadataChanged = this.metadataForm.updateProfile();
|
||||||
const securityChanged = this.securityForm.updateSecurity();
|
const securityChanged = this.updateSecurity();
|
||||||
if (!metadataChanged && !securityChanged) {
|
if (!metadataChanged && !securityChanged) {
|
||||||
this.notificationsService.warning(
|
this.notificationsService.warning(
|
||||||
this.translate.instant(this.NOTIFICATIONS_PREFIX + 'warning.no-changes.title'),
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -12,6 +12,9 @@ import { ProfilePageSecurityFormComponent } from './profile-page-security-form/p
|
|||||||
CommonModule,
|
CommonModule,
|
||||||
SharedModule
|
SharedModule
|
||||||
],
|
],
|
||||||
|
exports: [
|
||||||
|
ProfilePageSecurityFormComponent
|
||||||
|
],
|
||||||
declarations: [
|
declarations: [
|
||||||
ProfilePageComponent,
|
ProfilePageComponent,
|
||||||
ProfilePageMetadataFormComponent,
|
ProfilePageMetadataFormComponent,
|
||||||
|
@@ -0,0 +1,36 @@
|
|||||||
|
<div class="container">
|
||||||
|
<h2>{{MESSAGE_PREFIX + '.header'|translate}}</h2>
|
||||||
|
<p>{{MESSAGE_PREFIX + '.info' | translate}}</p>
|
||||||
|
|
||||||
|
<form [class]="'ng-invalid'" [formGroup]="form" (ngSubmit)="register()">
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<label class="font-weight-bold"
|
||||||
|
for="email">{{MESSAGE_PREFIX + '.email' | translate}}</label>
|
||||||
|
<input [className]="(email.invalid) && (email.dirty || email.touched) ? 'form-control is-invalid' :'form-control'"
|
||||||
|
type="text" id="email" formControlName="email"/>
|
||||||
|
<div *ngIf="email.invalid && (email.dirty || email.touched)"
|
||||||
|
class="invalid-feedback show-feedback">
|
||||||
|
<span *ngIf="email.errors && email.errors.required">
|
||||||
|
{{ MESSAGE_PREFIX + '.email.error.required' | translate }}
|
||||||
|
</span>
|
||||||
|
<span *ngIf="email.errors && email.errors.pattern">
|
||||||
|
{{ MESSAGE_PREFIX + '.email.error.pattern' | translate }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
{{MESSAGE_PREFIX + '.email.hint' |translate}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<button class="btn btn-primary"
|
||||||
|
[disabled]="form.invalid"
|
||||||
|
(click)="register()">{{MESSAGE_PREFIX + '.submit'| translate}}</button>
|
||||||
|
</div>
|
@@ -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<RegisterEmailFormComponent>;
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
73
src/app/register-email-form/register-email-form.component.ts
Normal file
73
src/app/register-email-form/register-email-form.component.ts
Normal file
@@ -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');
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
26
src/app/register-email-form/register-email-form.module.ts
Normal file
26
src/app/register-email-form/register-email-form.module.ts
Normal file
@@ -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 {
|
||||||
|
|
||||||
|
}
|
33
src/app/register-email-form/registration.resolver.spec.ts
Normal file
33
src/app/register-email-form/registration.resolver.spec.ts
Normal file
@@ -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');
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
20
src/app/register-email-form/registration.resolver.ts
Normal file
20
src/app/register-email-form/registration.resolver.ts
Normal file
@@ -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<Registration> {
|
||||||
|
|
||||||
|
constructor(private epersonRegistrationService: EpersonRegistrationService) {
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<Registration> {
|
||||||
|
const token = route.params.token;
|
||||||
|
return this.epersonRegistrationService.searchByToken(token);
|
||||||
|
}
|
||||||
|
}
|
@@ -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);
|
||||||
|
}));
|
||||||
|
});
|
19
src/app/register-page/create-profile/confirmed.validator.ts
Normal file
19
src/app/register-page/create-profile/confirmed.validator.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
@@ -0,0 +1,91 @@
|
|||||||
|
<div class="container">
|
||||||
|
<h3 class="mb-4">{{'register-page.create-profile.header' | translate}}</h3>
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">{{'register-page.create-profile.identification.header' | translate}}</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<label class="font-weight-bold"
|
||||||
|
for="email">{{'register-page.create-profile.identification.email' | translate}}</label>
|
||||||
|
<span id="email">{{(registration$ |async).email}}</span></div>
|
||||||
|
</div>
|
||||||
|
<form [class]="'ng-invalid'" [formGroup]="userInfoForm" (ngSubmit)="submitEperson()">
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<label for="firstName">{{'register-page.create-profile.identification.first-name' | translate}}</label>
|
||||||
|
<input [className]="(firstName.invalid) && (firstName.dirty || firstName.touched) ? 'form-control is-invalid' :'form-control'"
|
||||||
|
type="text" id="firstName" formControlName="firstName"/>
|
||||||
|
<div *ngIf="firstName.invalid && (firstName.dirty || firstName.touched)"
|
||||||
|
class="invalid-feedback show-feedback">
|
||||||
|
<span *ngIf="firstName.errors && firstName.errors.required">
|
||||||
|
{{ 'register-page.create-profile.identification.first-name.error' | translate }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<label
|
||||||
|
for="lastName">{{'register-page.create-profile.identification.last-name' | translate}}</label>
|
||||||
|
<input
|
||||||
|
[className]="(lastName.invalid) && (lastName.dirty || lastName.touched) ? 'form-control is-invalid' :'form-control'"
|
||||||
|
id="lastName" formControlName="lastName">
|
||||||
|
<div *ngIf="lastName.invalid && (lastName.dirty || lastName.touched)"
|
||||||
|
class="invalid-feedback show-feedback">
|
||||||
|
<span *ngIf="lastName.errors && lastName.errors.required">
|
||||||
|
{{ 'register-page.create-profile.identification.last-name.error' | translate }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<label
|
||||||
|
for="contactPhone">{{'register-page.create-profile.identification.contact' | translate}}</label>
|
||||||
|
<input class="form-control" id="contactPhone" formControlName="contactPhone">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<label
|
||||||
|
for="language">{{'register-page.create-profile.identification.language' |translate}}</label>
|
||||||
|
|
||||||
|
<select id="language" formControlName="language" class="form-control">
|
||||||
|
<option [value]="''"></option>
|
||||||
|
<option *ngFor="let lang of activeLangs" [value]="lang.code">{{lang.label}}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">{{'register-page.create-profile.security.header' | translate}}</div>
|
||||||
|
<div class="card-body">
|
||||||
|
|
||||||
|
<ds-profile-page-security-form
|
||||||
|
[passwordCanBeEmpty]="false"
|
||||||
|
[FORM_PREFIX]="'register-page.create-profile.security.'"
|
||||||
|
(isInvalid)="setInValid($event)"
|
||||||
|
(passwordValue)="setPasswordValue($event)"
|
||||||
|
></ds-profile-page-security-form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<button
|
||||||
|
[disabled]="isInValidPassword || userInfoForm.invalid"
|
||||||
|
class="btn btn-default btn-primary"
|
||||||
|
(click)="submitEperson()">{{'register-page.create-profile.submit' | translate}}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
@@ -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<CreateProfileComponent>;
|
||||||
|
|
||||||
|
let router;
|
||||||
|
let route;
|
||||||
|
let ePersonDataService: EPersonDataService;
|
||||||
|
let notificationsService;
|
||||||
|
let store: Store<CoreState>;
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
|
});
|
155
src/app/register-page/create-profile/create-profile.component.ts
Normal file
155
src/app/register-page/create-profile/create-profile.component.ts
Normal file
@@ -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<Registration>;
|
||||||
|
|
||||||
|
email: string;
|
||||||
|
token: string;
|
||||||
|
|
||||||
|
isInValidPassword = true;
|
||||||
|
password: string;
|
||||||
|
|
||||||
|
userInfoForm: FormGroup;
|
||||||
|
activeLangs: LangConfig[];
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private translateService: TranslateService,
|
||||||
|
private ePersonDataService: EPersonDataService,
|
||||||
|
private store: Store<CoreState>,
|
||||||
|
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'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@@ -0,0 +1,3 @@
|
|||||||
|
<ds-register-email-form
|
||||||
|
[MESSAGE_PREFIX]="'register-page.registration'">
|
||||||
|
</ds-register-email-form>
|
@@ -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<RegisterEmailComponent>;
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
@@ -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 {
|
||||||
|
|
||||||
|
}
|
32
src/app/register-page/register-page-routing.module.ts
Normal file
32
src/app/register-page/register-page-routing.module.ts
Normal file
@@ -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 {
|
||||||
|
}
|
31
src/app/register-page/register-page.module.ts
Normal file
31
src/app/register-page/register-page.module.ts
Normal file
@@ -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 {
|
||||||
|
|
||||||
|
}
|
@@ -0,0 +1,6 @@
|
|||||||
|
<a *ngIf="!(isAuthenticated$ | async)" [href]="href" [download]="download"><ng-container *ngTemplateOutlet="content"></ng-container></a>
|
||||||
|
<a *ngIf="(isAuthenticated$ | async)" [href]="href" [download]="download" (click)="downloadFile()"><ng-container *ngTemplateOutlet="content"></ng-container></a>
|
||||||
|
|
||||||
|
<ng-template #content>
|
||||||
|
<ng-content></ng-content>
|
||||||
|
</ng-template>
|
@@ -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<FileDownloadLinkComponent>;
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@@ -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<boolean>;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@@ -8,6 +8,6 @@
|
|||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
<div class="dropdown-divider"></div>
|
<div class="dropdown-divider"></div>
|
||||||
<a class="dropdown-item" href="#">{{"login.form.new-user" | translate}}</a>
|
<a class="dropdown-item" [routerLink]="[getRegisterPath()]">{{"login.form.new-user" | translate}}</a>
|
||||||
<a class="dropdown-item" href="#">{{"login.form.forgot-password" | translate}}</a>
|
<a class="dropdown-item" [routerLink]="[getForgotPath()]">{{"login.form.forgot-password" | translate}}</a>
|
||||||
</div>
|
</div>
|
||||||
|
@@ -1,7 +1,7 @@
|
|||||||
import { Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
|
import { Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
|
||||||
import { async, ComponentFixture, inject, TestBed } from '@angular/core/testing';
|
import { async, ComponentFixture, inject, TestBed } from '@angular/core/testing';
|
||||||
import { By } from '@angular/platform-browser';
|
import { By } from '@angular/platform-browser';
|
||||||
import { Store, StoreModule } from '@ngrx/store';
|
import { StoreModule } from '@ngrx/store';
|
||||||
|
|
||||||
import { LogInComponent } from './log-in.component';
|
import { LogInComponent } from './log-in.component';
|
||||||
import { authReducer } from '../../core/auth/auth.reducer';
|
import { authReducer } from '../../core/auth/auth.reducer';
|
||||||
@@ -13,11 +13,11 @@ import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
|||||||
import { SharedModule } from '../shared.module';
|
import { SharedModule } from '../shared.module';
|
||||||
import { NativeWindowMockFactory } from '../mocks/mock-native-window-ref';
|
import { NativeWindowMockFactory } from '../mocks/mock-native-window-ref';
|
||||||
import { ActivatedRouteStub } from '../testing/active-router.stub';
|
import { ActivatedRouteStub } from '../testing/active-router.stub';
|
||||||
import { ActivatedRoute, Router } from '@angular/router';
|
import { ActivatedRoute } from '@angular/router';
|
||||||
import { RouterStub } from '../testing/router.stub';
|
|
||||||
import { NativeWindowService } from '../../core/services/window.service';
|
import { NativeWindowService } from '../../core/services/window.service';
|
||||||
import { provideMockStore } from '@ngrx/store/testing';
|
import { provideMockStore } from '@ngrx/store/testing';
|
||||||
import { createTestComponent } from '../testing/utils.test';
|
import { createTestComponent } from '../testing/utils.test';
|
||||||
|
import { RouterTestingModule } from '@angular/router/testing';
|
||||||
|
|
||||||
describe('LogInComponent', () => {
|
describe('LogInComponent', () => {
|
||||||
|
|
||||||
@@ -46,6 +46,7 @@ describe('LogInComponent', () => {
|
|||||||
strictActionImmutability: false
|
strictActionImmutability: false
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
RouterTestingModule,
|
||||||
SharedModule,
|
SharedModule,
|
||||||
TranslateModule.forRoot()
|
TranslateModule.forRoot()
|
||||||
],
|
],
|
||||||
@@ -55,7 +56,7 @@ describe('LogInComponent', () => {
|
|||||||
providers: [
|
providers: [
|
||||||
{ provide: AuthService, useClass: AuthServiceStub },
|
{ provide: AuthService, useClass: AuthServiceStub },
|
||||||
{ provide: NativeWindowService, useFactory: NativeWindowMockFactory },
|
{ provide: NativeWindowService, useFactory: NativeWindowMockFactory },
|
||||||
{ provide: Router, useValue: new RouterStub() },
|
// { provide: Router, useValue: new RouterStub() },
|
||||||
{ provide: ActivatedRoute, useValue: new ActivatedRouteStub() },
|
{ provide: ActivatedRoute, useValue: new ActivatedRouteStub() },
|
||||||
provideMockStore({ initialState }),
|
provideMockStore({ initialState }),
|
||||||
LogInComponent
|
LogInComponent
|
||||||
|
@@ -8,6 +8,7 @@ import { AuthMethod } from '../../core/auth/models/auth.method';
|
|||||||
import { getAuthenticationMethods, isAuthenticated, isAuthenticationLoading } from '../../core/auth/selectors';
|
import { getAuthenticationMethods, isAuthenticated, isAuthenticationLoading } from '../../core/auth/selectors';
|
||||||
import { CoreState } from '../../core/core.reducers';
|
import { CoreState } from '../../core/core.reducers';
|
||||||
import { AuthService } from '../../core/auth/auth.service';
|
import { AuthService } from '../../core/auth/auth.service';
|
||||||
|
import { getForgotPasswordPath, getRegisterPath } from '../../app-routing.module';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* /users/sign-in
|
* /users/sign-in
|
||||||
@@ -82,4 +83,11 @@ export class LogInComponent implements OnInit, OnDestroy {
|
|||||||
this.alive = false;
|
this.alive = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getRegisterPath() {
|
||||||
|
return getRegisterPath();
|
||||||
|
}
|
||||||
|
|
||||||
|
getForgotPath() {
|
||||||
|
return getForgotPasswordPath();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -202,6 +202,7 @@ import { ResourcePolicyTargetResolver } from './resource-policies/resolvers/reso
|
|||||||
import { ResourcePolicyResolver } from './resource-policies/resolvers/resource-policy.resolver';
|
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 { 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 { 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';
|
import { CollectionDropdownComponent } from './collection-dropdown/collection-dropdown.component';
|
||||||
|
|
||||||
const MODULES = [
|
const MODULES = [
|
||||||
@@ -388,6 +389,7 @@ const COMPONENTS = [
|
|||||||
EpersonGroupListComponent,
|
EpersonGroupListComponent,
|
||||||
EpersonSearchBoxComponent,
|
EpersonSearchBoxComponent,
|
||||||
GroupSearchBoxComponent,
|
GroupSearchBoxComponent,
|
||||||
|
FileDownloadLinkComponent,
|
||||||
CollectionDropdownComponent
|
CollectionDropdownComponent
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -461,7 +463,8 @@ const ENTRY_COMPONENTS = [
|
|||||||
ClaimedTaskActionsApproveComponent,
|
ClaimedTaskActionsApproveComponent,
|
||||||
ClaimedTaskActionsRejectComponent,
|
ClaimedTaskActionsRejectComponent,
|
||||||
ClaimedTaskActionsReturnToPoolComponent,
|
ClaimedTaskActionsReturnToPoolComponent,
|
||||||
ClaimedTaskActionsEditMetadataComponent
|
ClaimedTaskActionsEditMetadataComponent,
|
||||||
|
FileDownloadLinkComponent,
|
||||||
];
|
];
|
||||||
|
|
||||||
const SHARED_ITEM_PAGE_COMPONENTS = [
|
const SHARED_ITEM_PAGE_COMPONENTS = [
|
||||||
|
@@ -9,6 +9,7 @@ import { EPersonMock } from './eperson.mock';
|
|||||||
export class AuthRequestServiceStub {
|
export class AuthRequestServiceStub {
|
||||||
protected mockUser: EPerson = EPersonMock;
|
protected mockUser: EPerson = EPersonMock;
|
||||||
protected mockTokenInfo = new AuthTokenInfo('test_token');
|
protected mockTokenInfo = new AuthTokenInfo('test_token');
|
||||||
|
protected mockShortLivedToken = 'test-shortlived-token';
|
||||||
|
|
||||||
public postToEndpoint(method: string, body: any, options?: HttpOptions): Observable<any> {
|
public postToEndpoint(method: string, body: any, options?: HttpOptions): Observable<any> {
|
||||||
const authStatusStub: AuthStatus = new AuthStatus();
|
const authStatusStub: AuthStatus = new AuthStatus();
|
||||||
@@ -82,4 +83,8 @@ export class AuthRequestServiceStub {
|
|||||||
}
|
}
|
||||||
return obj;
|
return obj;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getShortlivedToken() {
|
||||||
|
return observableOf(this.mockShortLivedToken);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -948,6 +948,62 @@
|
|||||||
"footer.link.duraspace": "DuraSpace",
|
"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": "Add",
|
||||||
|
|
||||||
"form.add-help": "Click here to add the current entry and to add another one",
|
"form.add-help": "Click here to add the current entry and to add another one",
|
||||||
@@ -2056,6 +2112,74 @@
|
|||||||
"publication.search.title": "DSpace Angular :: Publication Search",
|
"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",
|
"relationships.isAuthorOf": "Authors",
|
||||||
|
|
||||||
|
@@ -10958,9 +10958,9 @@ websocket-driver@>=0.5.1:
|
|||||||
websocket-extensions ">=0.1.1"
|
websocket-extensions ">=0.1.1"
|
||||||
|
|
||||||
websocket-extensions@>=0.1.1:
|
websocket-extensions@>=0.1.1:
|
||||||
version "0.1.3"
|
version "0.1.4"
|
||||||
resolved "https://registry.yarnpkg.com/websocket-extensions/-/websocket-extensions-0.1.3.tgz#5d2ff22977003ec687a4b87073dfbbac146ccf29"
|
resolved "https://registry.yarnpkg.com/websocket-extensions/-/websocket-extensions-0.1.4.tgz#7f8473bc839dfd87608adb95d7eb075211578a42"
|
||||||
integrity sha512-nqHUnMXmBzT0w570r2JpJxfiSD1IzoI+HGVdd3aZ0yNi3ngvQ4jv1dtHt5VGxfI2yj5yqImPhOK4vmIh2xMbGg==
|
integrity sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==
|
||||||
|
|
||||||
when@~3.6.x:
|
when@~3.6.x:
|
||||||
version "3.6.4"
|
version "3.6.4"
|
||||||
|
Reference in New Issue
Block a user