diff --git a/src/app/+admin/admin-sidebar/expandable-admin-sidebar-section/expandable-admin-sidebar-section.component.scss b/src/app/+admin/admin-sidebar/expandable-admin-sidebar-section/expandable-admin-sidebar-section.component.scss
index 37fe15bd40..1f6e288608 100644
--- a/src/app/+admin/admin-sidebar/expandable-admin-sidebar-section/expandable-admin-sidebar-section.component.scss
+++ b/src/app/+admin/admin-sidebar/expandable-admin-sidebar-section/expandable-admin-sidebar-section.component.scss
@@ -1,4 +1,4 @@
-::ng-deep {
+:host ::ng-deep {
.fa-chevron-right {
padding-left: $spacer/2;
font-size: 0.5rem;
@@ -16,4 +16,4 @@
display: flex;
flex-direction: column;
}
-}
\ No newline at end of file
+}
diff --git a/src/app/+item-page/full/field-components/file-section/full-file-section.component.html b/src/app/+item-page/full/field-components/file-section/full-file-section.component.html
index b8ab9bdb41..7c1719eb82 100644
--- a/src/app/+item-page/full/field-components/file-section/full-file-section.component.html
+++ b/src/app/+item-page/full/field-components/file-section/full-file-section.component.html
@@ -21,9 +21,9 @@
diff --git a/src/app/+item-page/simple/field-components/file-section/file-section.component.html b/src/app/+item-page/simple/field-components/file-section/file-section.component.html
index 6533322e03..17e4a795e7 100644
--- a/src/app/+item-page/simple/field-components/file-section/file-section.component.html
+++ b/src/app/+item-page/simple/field-components/file-section/file-section.component.html
@@ -1,11 +1,11 @@
0" [label]="label | translate">
diff --git a/src/app/community-list-page/community-list-datasource.ts b/src/app/community-list-page/community-list-datasource.ts
index 3a9d9f2077..b77cbb5246 100644
--- a/src/app/community-list-page/community-list-datasource.ts
+++ b/src/app/community-list-page/community-list-datasource.ts
@@ -1,3 +1,5 @@
+import { NgZone } from '@angular/core';
+import { FindListOptions } from '../core/data/request.models';
import { CommunityListService, FlatNode } from './community-list-service';
import { CollectionViewer, DataSource } from '@angular/cdk/typings/collections';
import { BehaviorSubject, Observable, } from 'rxjs';
@@ -14,21 +16,23 @@ export class CommunityListDatasource implements DataSource {
private communityList$ = new BehaviorSubject([]);
public loading$ = new BehaviorSubject(false);
- constructor(private communityListService: CommunityListService) {
+ constructor(private communityListService: CommunityListService,
+ private zone: NgZone) {
}
connect(collectionViewer: CollectionViewer): Observable {
return this.communityList$.asObservable();
}
- loadCommunities(expandedNodes: FlatNode[]) {
+ loadCommunities(findOptions: FindListOptions, expandedNodes: FlatNode[]) {
this.loading$.next(true);
-
- this.communityListService.loadCommunities(expandedNodes).pipe(
- take(1),
- finalize(() => this.loading$.next(false)),
- ).subscribe((flatNodes: FlatNode[]) => {
- this.communityList$.next(flatNodes);
+ this.zone.runOutsideAngular(() => {
+ this.communityListService.loadCommunities(findOptions, expandedNodes).pipe(
+ take(1),
+ finalize(() => this.zone.run(() => this.loading$.next(false))),
+ ).subscribe((flatNodes: FlatNode[]) => {
+ this.zone.run(() => this.communityList$.next(flatNodes));
+ });
});
}
diff --git a/src/app/community-list-page/community-list-service.spec.ts b/src/app/community-list-page/community-list-service.spec.ts
index f4955b2a36..accd0f23a5 100644
--- a/src/app/community-list-page/community-list-service.spec.ts
+++ b/src/app/community-list-page/community-list-service.spec.ts
@@ -1,21 +1,19 @@
-import { of as observableOf } from 'rxjs';
-import { TestBed, inject, async } from '@angular/core/testing';
+import { inject, TestBed } from '@angular/core/testing';
import { Store } from '@ngrx/store';
+import { of as observableOf } from 'rxjs';
+import { take } from 'rxjs/operators';
import { AppState } from '../app.reducer';
+import { SortDirection, SortOptions } from '../core/cache/models/sort-options.model';
+import { PaginatedList } from '../core/data/paginated-list';
+import { createFailedRemoteDataObject$, createSuccessfulRemoteDataObject$ } from '../shared/remote-data.utils';
import { StoreMock } from '../shared/testing/store.mock';
import { CommunityListService, FlatNode, toFlatNode } from './community-list-service';
import { CollectionDataService } from '../core/data/collection-data.service';
-import { PaginatedList } from '../core/data/paginated-list';
-import { PageInfo } from '../core/shared/page-info.model';
import { CommunityDataService } from '../core/data/community-data.service';
-import {
- createFailedRemoteDataObject$,
- createSuccessfulRemoteDataObject$
-} from '../shared/remote-data.utils';
import { Community } from '../core/shared/community.model';
import { Collection } from '../core/shared/collection.model';
-import { take } from 'rxjs/operators';
import { FindListOptions } from '../core/data/request.models';
+import { PageInfo } from '../core/shared/page-info.model';
describe('CommunityListService', () => {
let store: StoreMock;
@@ -212,9 +210,11 @@ describe('CommunityListService', () => {
let findTopSpy;
beforeEach((done) => {
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))
.subscribe((value) => {
flatNodeList = value;
@@ -240,7 +240,10 @@ describe('CommunityListService', () => {
let flatNodeList;
describe('None expanded: should return list containing only flatnodes of the test top communities', () => {
beforeEach((done) => {
- service.loadCommunities(null)
+ service.loadCommunities({
+ currentPage: 1,
+ sort: new SortOptions('dc.title', SortDirection.ASC)
+ }, null)
.pipe(take(1))
.subscribe((value) => {
flatNodeList = value;
@@ -270,7 +273,10 @@ describe('CommunityListService', () => {
communityFlatNode.currentCommunityPage = 1;
expandedNodes.push(communityFlatNode);
});
- service.loadCommunities(expandedNodes)
+ service.loadCommunities({
+ currentPage: 1,
+ sort: new SortOptions('dc.title', SortDirection.ASC)
+ }, expandedNodes)
.pipe(take(1))
.subscribe((value) => {
flatNodeList = value;
@@ -295,7 +301,10 @@ describe('CommunityListService', () => {
communityFlatNode.currentCollectionPage = 1;
communityFlatNode.currentCommunityPage = 1;
const expandedNodes = [communityFlatNode];
- service.loadCommunities(expandedNodes)
+ service.loadCommunities({
+ currentPage: 1,
+ sort: new SortOptions('dc.title', SortDirection.ASC)
+ }, expandedNodes)
.pipe(take(1))
.subscribe((value) => {
flatNodeList = value;
@@ -317,7 +326,10 @@ describe('CommunityListService', () => {
communityFlatNode.currentCollectionPage = 2;
communityFlatNode.currentCommunityPage = 1;
const expandedNodes = [communityFlatNode];
- service.loadCommunities(expandedNodes)
+ service.loadCommunities({
+ currentPage: 1,
+ sort: new SortOptions('dc.title', SortDirection.ASC)
+ }, expandedNodes)
.pipe(take(1))
.subscribe((value) => {
flatNodeList = value;
diff --git a/src/app/community-list-page/community-list-service.ts b/src/app/community-list-page/community-list-service.ts
index be04887e71..a5c3506e3d 100644
--- a/src/app/community-list-page/community-list-service.ts
+++ b/src/app/community-list-page/community-list-service.ts
@@ -4,11 +4,12 @@ import { combineLatest as observableCombineLatest } from 'rxjs/internal/observab
import { Observable, of as observableOf } from 'rxjs';
import { AppState } from '../app.reducer';
import { CommunityDataService } from '../core/data/community-data.service';
-import { PaginationComponentOptions } from '../shared/pagination/pagination-component-options.model';
-import { SortDirection, SortOptions } from '../core/cache/models/sort-options.model';
-import { catchError, filter, map, switchMap, take } from 'rxjs/operators';
+import { FindListOptions } from '../core/data/request.models';
+import { map, flatMap } from 'rxjs/operators';
import { Community } from '../core/shared/community.model';
import { Collection } from '../core/shared/collection.model';
+import { getSucceededRemoteData } from '../core/shared/operators';
+import { PageInfo } from '../core/shared/page-info.model';
import { hasValue, isNotEmpty } from '../shared/empty.util';
import { RemoteData } from '../core/data/remote-data';
import { PaginatedList } from '../core/data/paginated-list';
@@ -46,8 +47,7 @@ export class ShowMoreFlatNode {
// Helper method to combine an flatten an array of observables of flatNode arrays
export const combineAndFlatten = (obsList: Array>): Observable =>
observableCombineLatest(...obsList).pipe(
- map((matrix: FlatNode[][]) =>
- matrix.reduce((combinedList, currentList: FlatNode[]) => [...combinedList, ...currentList]))
+ map((matrix: any[][]) => [].concat(...matrix))
);
/**
@@ -99,6 +99,8 @@ const communityListStateSelector = (state: AppState) => state.communityList;
const expandedNodesSelector = createSelector(communityListStateSelector, (communityList: CommunityListState) => communityList.expandedNodes);
const loadingNodeSelector = createSelector(communityListStateSelector, (communityList: CommunityListState) => communityList.loadingNode);
+export const MAX_COMCOLS_PER_PAGE = 50;
+
/**
* Service class for the community list, responsible for the creating of the flat list used by communityList dataSource
* and connection to the store to retrieve and save the state of the community list
@@ -107,26 +109,8 @@ const loadingNodeSelector = createSelector(communityListStateSelector, (communit
@Injectable()
export class CommunityListService {
- // page-limited list of top-level communities
- payloads$: Array>>;
-
- topCommunitiesConfig: PaginationComponentOptions;
- topCommunitiesSortConfig: SortOptions;
-
- maxSubCommunitiesPerPage: number;
- maxCollectionsPerPage: number;
-
constructor(private communityDataService: CommunityDataService, private collectionDataService: CollectionDataService,
private store: Store) {
- this.topCommunitiesConfig = new PaginationComponentOptions();
- this.topCommunitiesConfig.id = 'top-level-pagination';
- this.topCommunitiesConfig.pageSize = 10;
- this.topCommunitiesConfig.currentPage = 1;
- this.topCommunitiesSortConfig = new SortOptions('dc.title', SortDirection.ASC);
- this.initTopCommunityList();
-
- this.maxSubCommunitiesPerPage = 3;
- this.maxCollectionsPerPage = 3;
}
saveCommunityListStateToStore(expandedNodes: FlatNode[], loadingNode: FlatNode): void {
@@ -141,57 +125,46 @@ export class CommunityListService {
return this.store.select(loadingNodeSelector);
}
- /**
- * Increases the payload so it contains the next page of top level communities
- */
- getNextPageTopCommunities(): void {
- this.topCommunitiesConfig.currentPage = this.topCommunitiesConfig.currentPage + 1;
- this.payloads$ = [...this.payloads$, this.communityDataService.findTop({
- currentPage: this.topCommunitiesConfig.currentPage,
- elementsPerPage: this.topCommunitiesConfig.pageSize,
- sort: {
- field: this.topCommunitiesSortConfig.field,
- direction: this.topCommunitiesSortConfig.direction
- }
- }).pipe(
- take(1),
- map((results) => results.payload),
- )];
- }
-
/**
* Gets all top communities, limited by page, and transforms this in a list of flatNodes.
* @param expandedNodes List of expanded nodes; if a node is not expanded its subCommunities and collections need
* not be added to the list
*/
- loadCommunities(expandedNodes: FlatNode[]): Observable {
- const res = this.payloads$.map((payload) => {
- return payload.pipe(
- take(1),
- switchMap((result: PaginatedList) => {
- return this.transformListOfCommunities(result, 0, null, expandedNodes);
- }),
- catchError(() => observableOf([])),
- );
- });
- return combineAndFlatten(res);
+ loadCommunities(findOptions: FindListOptions, expandedNodes: FlatNode[]): Observable {
+ const currentPage = findOptions.currentPage;
+ const topCommunities = [];
+ for (let i = 1; i <= currentPage; i++) {
+ const pagination: FindListOptions = Object.assign({}, findOptions, { currentPage: i });
+ topCommunities.push(this.getTopCommunities(pagination));
+ }
+ const topComs$ = observableCombineLatest(...topCommunities).pipe(
+ map((coms: Array>) => {
+ const newPages: Community[][] = coms.map((unit: PaginatedList) => unit.page);
+ const newPage: Community[] = [].concat(...newPages);
+ let newPageInfo = new PageInfo();
+ if (coms && coms.length > 0) {
+ newPageInfo = Object.assign({}, coms[0].pageInfo, { currentPage })
+ }
+ return new PaginatedList(newPageInfo, newPage);
+ })
+ );
+ return topComs$.pipe(flatMap((topComs: PaginatedList) => this.transformListOfCommunities(topComs, 0, null, expandedNodes)));
};
/**
* Puts the initial top level communities in a list to be called upon
*/
- private initTopCommunityList(): void {
- this.payloads$ = [this.communityDataService.findTop({
- currentPage: this.topCommunitiesConfig.currentPage,
- elementsPerPage: this.topCommunitiesConfig.pageSize,
+ private getTopCommunities(options: FindListOptions): Observable> {
+ return this.communityDataService.findTop({
+ currentPage: options.currentPage,
+ elementsPerPage: MAX_COMCOLS_PER_PAGE,
sort: {
- field: this.topCommunitiesSortConfig.field,
- direction: this.topCommunitiesSortConfig.direction
+ field: options.sort.field,
+ direction: options.sort.direction
}
}).pipe(
- take(1),
map((results) => results.payload),
- )];
+ );
}
/**
@@ -206,16 +179,15 @@ export class CommunityListService {
parent: FlatNode,
expandedNodes: FlatNode[]): Observable {
if (isNotEmpty(listOfPaginatedCommunities.page)) {
- let currentPage = this.topCommunitiesConfig.currentPage;
+ let currentPage = listOfPaginatedCommunities.currentPage;
if (isNotEmpty(parent)) {
currentPage = expandedNodes.find((node: FlatNode) => node.id === parent.id).currentCommunityPage;
}
- const isNotAllCommunities = (listOfPaginatedCommunities.totalElements > (listOfPaginatedCommunities.elementsPerPage * currentPage));
let obsList = listOfPaginatedCommunities.page
.map((community: Community) => {
return this.transformCommunity(community, level, parent, expandedNodes)
});
- if (isNotAllCommunities && listOfPaginatedCommunities.currentPage > currentPage) {
+ if (currentPage < listOfPaginatedCommunities.totalPages && currentPage === listOfPaginatedCommunities.currentPage) {
obsList = [...obsList, observableOf([showMoreFlatNode('community', level, parent)])];
}
@@ -252,13 +224,12 @@ export class CommunityListService {
let subcoms = [];
for (let i = 1; i <= currentCommunityPage; i++) {
const nextSetOfSubcommunitiesPage = this.communityDataService.findByParent(community.uuid, {
- elementsPerPage: this.maxSubCommunitiesPerPage,
+ elementsPerPage: MAX_COMCOLS_PER_PAGE,
currentPage: i
})
.pipe(
- filter((rd: RemoteData>) => rd.hasSucceeded),
- take(1),
- switchMap((rd: RemoteData>) =>
+ getSucceededRemoteData(),
+ flatMap((rd: RemoteData>) =>
this.transformListOfCommunities(rd.payload, level + 1, communityFlatNode, expandedNodes))
);
@@ -271,16 +242,15 @@ export class CommunityListService {
let collections = [];
for (let i = 1; i <= currentCollectionPage; i++) {
const nextSetOfCollectionsPage = this.collectionDataService.findByParent(community.uuid, {
- elementsPerPage: this.maxCollectionsPerPage,
+ elementsPerPage: MAX_COMCOLS_PER_PAGE,
currentPage: i
})
.pipe(
- filter((rd: RemoteData>) => rd.hasSucceeded),
- take(1),
+ getSucceededRemoteData(),
map((rd: RemoteData>) => {
let nodes = rd.payload.page
.map((collection: Collection) => toFlatNode(collection, observableOf(false), level + 1, false, communityFlatNode));
- if ((rd.payload.elementsPerPage * currentCollectionPage) < rd.payload.totalElements && rd.payload.currentPage > currentCollectionPage) {
+ if (currentCollectionPage < rd.payload.totalPages && currentCollectionPage === rd.payload.currentPage) {
nodes = [...nodes, showMoreFlatNode('collection', level + 1, communityFlatNode)];
}
return nodes;
@@ -305,21 +275,18 @@ export class CommunityListService {
let hasColls$: Observable;
hasSubcoms$ = this.communityDataService.findByParent(community.uuid, { elementsPerPage: 1 })
.pipe(
- filter((rd: RemoteData>) => rd.hasSucceeded),
- take(1),
+ getSucceededRemoteData(),
map((results) => results.payload.totalElements > 0),
);
hasColls$ = this.collectionDataService.findByParent(community.uuid, { elementsPerPage: 1 })
.pipe(
- filter((rd: RemoteData>) => rd.hasSucceeded),
- take(1),
+ getSucceededRemoteData(),
map((results) => results.payload.totalElements > 0),
);
let hasChildren$: Observable;
hasChildren$ = observableCombineLatest(hasSubcoms$, hasColls$).pipe(
- take(1),
map(([hasSubcoms, hasColls]: [boolean, boolean]) => {
if (hasSubcoms || hasColls) {
return true;
diff --git a/src/app/community-list-page/community-list/community-list.component.spec.ts b/src/app/community-list-page/community-list/community-list.component.spec.ts
index a91c5fa057..ef9e89ff1b 100644
--- a/src/app/community-list-page/community-list/community-list.component.spec.ts
+++ b/src/app/community-list-page/community-list/community-list.component.spec.ts
@@ -114,15 +114,9 @@ describe('CommunityListComponent', () => {
beforeEach(async(() => {
communityListServiceStub = {
- topPageSize: 2,
- topCurrentPage: 1,
- collectionPageSize: 2,
- subcommunityPageSize: 2,
+ pageSize: 2,
expandedNodes: [],
loadingNode: null,
- getNextPageTopCommunities() {
- this.topCurrentPage++;
- },
getLoadingNodeFromStore() {
return observableOf(this.loadingNode);
},
@@ -133,12 +127,12 @@ describe('CommunityListComponent', () => {
this.expandedNodes = expandedNodes;
this.loadingNode = loadingNode;
},
- loadCommunities(expandedNodes) {
+ loadCommunities(options, expandedNodes) {
let flatnodes;
let showMoreTopComNode = false;
flatnodes = [...mockTopFlatnodesUnexpanded];
- const currentPage = this.topCurrentPage;
- const elementsPerPage = this.topPageSize;
+ const currentPage = options.currentPage;
+ const elementsPerPage = this.pageSize;
let endPageIndex = (currentPage * elementsPerPage);
if (endPageIndex >= flatnodes.length) {
endPageIndex = flatnodes.length;
@@ -171,14 +165,14 @@ describe('CommunityListComponent', () => {
collFlatnodes = [...collFlatnodes, toFlatNode(coll, observableOf(false), topNode.level + 1, false, topNode)];
});
if (isNotEmpty(subComFlatnodes)) {
- const endSubComIndex = this.subcommunityPageSize * expandedParent.currentCommunityPage;
+ const endSubComIndex = this.pageSize * expandedParent.currentCommunityPage;
flatnodes = [...flatnodes, ...subComFlatnodes.slice(0, endSubComIndex)];
if (subComFlatnodes.length > endSubComIndex) {
flatnodes = [...flatnodes, showMoreFlatNode('community', topNode.level + 1, expandedParent)];
}
}
if (isNotEmpty(collFlatnodes)) {
- const endColIndex = this.collectionPageSize * expandedParent.currentCollectionPage;
+ const endColIndex = this.pageSize * expandedParent.currentCollectionPage;
flatnodes = [...flatnodes, ...collFlatnodes.slice(0, endColIndex)];
if (collFlatnodes.length > endColIndex) {
flatnodes = [...flatnodes, showMoreFlatNode('collection', topNode.level + 1, expandedParent)];
diff --git a/src/app/community-list-page/community-list/community-list.component.ts b/src/app/community-list-page/community-list/community-list.component.ts
index ddcd49cd1c..be96ff1a0a 100644
--- a/src/app/community-list-page/community-list/community-list.component.ts
+++ b/src/app/community-list-page/community-list/community-list.component.ts
@@ -1,5 +1,7 @@
-import { Component, OnDestroy, OnInit } from '@angular/core';
+import { Component, NgZone, OnDestroy, OnInit } from '@angular/core';
import { take } from 'rxjs/operators';
+import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model';
+import { FindListOptions } from '../../core/data/request.models';
import { CommunityListService, FlatNode } from '../community-list-service';
import { CommunityListDatasource } from '../community-list-datasource';
import { FlatTreeControl } from '@angular/cdk/tree';
@@ -27,17 +29,24 @@ export class CommunityListComponent implements OnInit, OnDestroy {
dataSource: CommunityListDatasource;
- constructor(private communityListService: CommunityListService) {
+ paginationConfig: FindListOptions;
+
+ constructor(private communityListService: CommunityListService,
+ private zone: NgZone) {
+ this.paginationConfig = new FindListOptions();
+ this.paginationConfig.elementsPerPage = 2;
+ this.paginationConfig.currentPage = 1;
+ this.paginationConfig.sort = new SortOptions('dc.title', SortDirection.ASC);
}
ngOnInit() {
- this.dataSource = new CommunityListDatasource(this.communityListService);
+ this.dataSource = new CommunityListDatasource(this.communityListService, this.zone);
this.communityListService.getLoadingNodeFromStore().pipe(take(1)).subscribe((result) => {
this.loadingNode = result;
});
this.communityListService.getExpandedNodesFromStore().pipe(take(1)).subscribe((result) => {
this.expandedNodes = [...result];
- this.dataSource.loadCommunities(this.expandedNodes);
+ this.dataSource.loadCommunities(this.paginationConfig, this.expandedNodes);
});
}
@@ -74,7 +83,7 @@ export class CommunityListComponent implements OnInit, OnDestroy {
node.currentCommunityPage = 1;
}
}
- this.dataSource.loadCommunities(this.expandedNodes);
+ this.dataSource.loadCommunities(this.paginationConfig, this.expandedNodes);
}
/**
@@ -94,10 +103,10 @@ export class CommunityListComponent implements OnInit, OnDestroy {
const parentNodeInExpandedNodes = this.expandedNodes.find((node2: FlatNode) => node.parent.id === node2.id);
parentNodeInExpandedNodes.currentCommunityPage++;
}
- this.dataSource.loadCommunities(this.expandedNodes);
+ this.dataSource.loadCommunities(this.paginationConfig, this.expandedNodes);
} else {
- this.communityListService.getNextPageTopCommunities();
- this.dataSource.loadCommunities(this.expandedNodes);
+ this.paginationConfig.currentPage++;
+ this.dataSource.loadCommunities(this.paginationConfig, this.expandedNodes);
}
}
diff --git a/src/app/core/auth/auth-request.service.ts b/src/app/core/auth/auth-request.service.ts
index 465fb69dd2..93f55389f9 100644
--- a/src/app/core/auth/auth-request.service.ts
+++ b/src/app/core/auth/auth-request.service.ts
@@ -1,12 +1,18 @@
import { Observable, of as observableOf, throwError as observableThrowError } from 'rxjs';
-import { distinctUntilChanged, filter, map, mergeMap, tap } from 'rxjs/operators';
-import { Inject, Injectable } from '@angular/core';
+import { distinctUntilChanged, filter, map, mergeMap, switchMap, tap } from 'rxjs/operators';
+import { Injectable } from '@angular/core';
import { HALEndpointService } from '../shared/hal-endpoint.service';
import { RequestService } from '../data/request.service';
-import { GlobalConfig } from '../../../config/global-config.interface';
import { isNotEmpty } from '../../shared/empty.util';
-import { AuthGetRequest, AuthPostRequest, GetRequest, PostRequest, RestRequest } from '../data/request.models';
-import { AuthStatusResponse, ErrorResponse } from '../cache/response.models';
+import {
+ AuthGetRequest,
+ AuthPostRequest,
+ GetRequest,
+ PostRequest,
+ RestRequest,
+ TokenPostRequest
+} from '../data/request.models';
+import { AuthStatusResponse, ErrorResponse, TokenResponse } from '../cache/response.models';
import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service';
import { getResponseFromEntry } from '../shared/operators';
import { HttpClient } from '@angular/common/http';
@@ -15,6 +21,7 @@ import { HttpClient } from '@angular/common/http';
export class AuthRequestService {
protected linkName = 'authn';
protected browseEndpoint = '';
+ protected shortlivedtokensEndpoint = 'shortlivedtokens';
constructor(protected halService: HALEndpointService,
protected requestService: RequestService,
@@ -67,4 +74,19 @@ export class AuthRequestService {
mergeMap((request: GetRequest) => this.fetchRequest(request)),
distinctUntilChanged());
}
+
+ /**
+ * Send a POST request to retrieve a short-lived token which provides download access of restricted files
+ */
+ public getShortlivedToken(): Observable {
+ return this.halService.getEndpoint(`${this.linkName}/${this.shortlivedtokensEndpoint}`).pipe(
+ filter((href: string) => isNotEmpty(href)),
+ distinctUntilChanged(),
+ map((endpointURL: string) => new TokenPostRequest(this.requestService.generateRequestId(), endpointURL)),
+ tap((request: PostRequest) => this.requestService.configure(request)),
+ switchMap((request: PostRequest) => this.requestService.getByUUID(request.uuid)),
+ getResponseFromEntry(),
+ map((response: TokenResponse) => response.token)
+ );
+ }
}
diff --git a/src/app/core/auth/auth.service.spec.ts b/src/app/core/auth/auth.service.spec.ts
index 3b6fae4dd1..a15d604cc4 100644
--- a/src/app/core/auth/auth.service.spec.ts
+++ b/src/app/core/auth/auth.service.spec.ts
@@ -1,17 +1,14 @@
import { async, inject, TestBed } from '@angular/core/testing';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router } from '@angular/router';
-
import { Store, StoreModule } from '@ngrx/store';
import { REQUEST } from '@nguniversal/express-engine/tokens';
import { of as observableOf } from 'rxjs';
-
import { authReducer, AuthState } from './auth.reducer';
import { NativeWindowRef, NativeWindowService } from '../services/window.service';
import { AuthService, IMPERSONATING_COOKIE } from './auth.service';
import { RouterStub } from '../../shared/testing/router.stub';
import { ActivatedRouteStub } from '../../shared/testing/active-router.stub';
-
import { CookieService } from '../services/cookie.service';
import { AuthRequestServiceStub } from '../../shared/testing/auth-request-service.stub';
import { AuthRequestService } from './auth-request.service';
@@ -49,6 +46,7 @@ describe('AuthService test', () => {
let storage: CookieService;
let token: AuthTokenInfo;
let authenticatedState;
+ let unAuthenticatedState;
let linkService;
function init() {
@@ -67,6 +65,13 @@ describe('AuthService test', () => {
authToken: token,
user: EPersonMock
};
+ unAuthenticatedState = {
+ authenticated: false,
+ loaded: true,
+ loading: false,
+ authToken: undefined,
+ user: undefined
+ };
authRequest = new AuthRequestServiceStub();
routeStub = new ActivatedRouteStub();
linkService = {
@@ -214,6 +219,12 @@ describe('AuthService test', () => {
});
});
+ it('should return the shortlived token when user is logged in', () => {
+ authService.getShortlivedToken().subscribe((shortlivedToken: string) => {
+ expect(shortlivedToken).toEqual(authRequest.mockShortLivedToken);
+ });
+ });
+
it('should return token object when it is valid', () => {
authService.hasValidAuthenticationToken().subscribe((tokenState: AuthTokenInfo) => {
expect(tokenState).toBe(token);
@@ -448,4 +459,44 @@ describe('AuthService test', () => {
});
});
});
+
+ describe('when user is not logged in', () => {
+ beforeEach(async(() => {
+ init();
+ TestBed.configureTestingModule({
+ imports: [
+ StoreModule.forRoot({ authReducer }, {
+ runtimeChecks: {
+ strictStateImmutability: false,
+ strictActionImmutability: false
+ }
+ })
+ ],
+ providers: [
+ { provide: AuthRequestService, useValue: authRequest },
+ { provide: REQUEST, useValue: {} },
+ { provide: Router, useValue: routerStub },
+ { provide: RouteService, useValue: routeServiceStub },
+ { provide: RemoteDataBuildService, useValue: linkService },
+ CookieService,
+ AuthService
+ ]
+ }).compileComponents();
+ }));
+
+ beforeEach(inject([CookieService, AuthRequestService, Store, Router, RouteService], (cookieService: CookieService, authReqService: AuthRequestService, store: Store, router: Router, routeService: RouteService) => {
+ store
+ .subscribe((state) => {
+ (state as any).core = Object.create({});
+ (state as any).core.auth = unAuthenticatedState;
+ });
+ authService = new AuthService({}, window, undefined, authReqService, mockEpersonDataService, router, routeService, cookieService, store);
+ }));
+
+ it('should return null for the shortlived token', () => {
+ authService.getShortlivedToken().subscribe((shortlivedToken: string) => {
+ expect(shortlivedToken).toBeNull();
+ });
+ });
+ });
});
diff --git a/src/app/core/auth/auth.service.ts b/src/app/core/auth/auth.service.ts
index 588d9e2675..dc1ad4ddd7 100644
--- a/src/app/core/auth/auth.service.ts
+++ b/src/app/core/auth/auth.service.ts
@@ -546,4 +546,14 @@ export class AuthService {
return this.getImpersonateID() === epersonId;
}
+ /**
+ * Get a short-lived token for appending to download urls of restricted files
+ * Returns null if the user isn't authenticated
+ */
+ getShortlivedToken(): Observable {
+ return this.isAuthenticated().pipe(
+ switchMap((authenticated) => authenticated ? this.authRequestService.getShortlivedToken() : observableOf(null))
+ );
+ }
+
}
diff --git a/src/app/core/auth/token-response-parsing.service.spec.ts b/src/app/core/auth/token-response-parsing.service.spec.ts
new file mode 100644
index 0000000000..35927708f6
--- /dev/null
+++ b/src/app/core/auth/token-response-parsing.service.spec.ts
@@ -0,0 +1,45 @@
+import { TokenResponseParsingService } from './token-response-parsing.service';
+import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model';
+import { TokenResponse } from '../cache/response.models';
+
+describe('TokenResponseParsingService', () => {
+ let service: TokenResponseParsingService;
+
+ beforeEach(() => {
+ service = new TokenResponseParsingService();
+ });
+
+ describe('parse', () => {
+ it('should return a TokenResponse containing the token', () => {
+ const data = {
+ payload: {
+ token: 'valid-token'
+ },
+ statusCode: 200,
+ statusText: 'OK'
+ } as DSpaceRESTV2Response;
+ const expected = new TokenResponse(data.payload.token, true, 200, 'OK');
+ expect(service.parse(undefined, data)).toEqual(expected);
+ });
+
+ it('should return an empty TokenResponse when payload doesn\'t contain a token', () => {
+ const data = {
+ payload: {},
+ statusCode: 200,
+ statusText: 'OK'
+ } as DSpaceRESTV2Response;
+ const expected = new TokenResponse(null, false, 200, 'OK');
+ expect(service.parse(undefined, data)).toEqual(expected);
+ });
+
+ it('should return an error TokenResponse when the response failed', () => {
+ const data = {
+ payload: {},
+ statusCode: 400,
+ statusText: 'BAD REQUEST'
+ } as DSpaceRESTV2Response;
+ const expected = new TokenResponse(null, false, 400, 'BAD REQUEST');
+ expect(service.parse(undefined, data)).toEqual(expected);
+ });
+ });
+});
diff --git a/src/app/core/auth/token-response-parsing.service.ts b/src/app/core/auth/token-response-parsing.service.ts
new file mode 100644
index 0000000000..a1b1e23aa4
--- /dev/null
+++ b/src/app/core/auth/token-response-parsing.service.ts
@@ -0,0 +1,23 @@
+import { ResponseParsingService } from '../data/parsing.service';
+import { RestRequest } from '../data/request.models';
+import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model';
+import { RestResponse, TokenResponse } from '../cache/response.models';
+import { isNotEmpty } from '../../shared/empty.util';
+import { Injectable } from '@angular/core';
+
+@Injectable()
+/**
+ * A ResponseParsingService used to parse DSpaceRESTV2Response coming from the REST API to a token string
+ * wrapped in a TokenResponse
+ */
+export class TokenResponseParsingService implements ResponseParsingService {
+
+ parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse {
+ if (isNotEmpty(data.payload) && isNotEmpty(data.payload.token) && (data.statusCode === 200)) {
+ return new TokenResponse(data.payload.token, true, data.statusCode, data.statusText);
+ } else {
+ return new TokenResponse(null, false, data.statusCode, data.statusText)
+ }
+ }
+
+}
diff --git a/src/app/core/cache/builders/remote-data-build.service.ts b/src/app/core/cache/builders/remote-data-build.service.ts
index 88d1890de2..6c9f40888f 100644
--- a/src/app/core/cache/builders/remote-data-build.service.ts
+++ b/src/app/core/cache/builders/remote-data-build.service.ts
@@ -150,12 +150,7 @@ export class RemoteDataBuildService {
filterSuccessfulResponses(),
map((response: DSOSuccessResponse) => {
if (hasValue((response as DSOSuccessResponse).pageInfo)) {
- const resPageInfo = (response as DSOSuccessResponse).pageInfo;
- if (isNotEmpty(resPageInfo) && resPageInfo.currentPage >= 0) {
- return Object.assign({}, resPageInfo, { currentPage: resPageInfo.currentPage + 1 });
- } else {
- return resPageInfo;
- }
+ return (response as DSOSuccessResponse).pageInfo;
}
})
);
diff --git a/src/app/core/cache/response.models.ts b/src/app/core/cache/response.models.ts
index b40965dd0a..e0d0e21ad9 100644
--- a/src/app/core/cache/response.models.ts
+++ b/src/app/core/cache/response.models.ts
@@ -166,6 +166,20 @@ export class AuthStatusResponse extends RestResponse {
}
}
+/**
+ * A REST Response containing a token
+ */
+export class TokenResponse extends RestResponse {
+ constructor(
+ public token: string,
+ public isSuccessful: boolean,
+ public statusCode: number,
+ public statusText: string
+ ) {
+ super(isSuccessful, statusCode, statusText);
+ }
+}
+
export class IntegrationSuccessResponse extends RestResponse {
constructor(
public dataDefinition: PaginatedList,
diff --git a/src/app/core/core.module.ts b/src/app/core/core.module.ts
index b521b29a1f..badec23d55 100644
--- a/src/app/core/core.module.ts
+++ b/src/app/core/core.module.ts
@@ -146,6 +146,7 @@ import { WorkflowActionDataService } from './data/workflow-action-data.service';
import { WorkflowAction } from './tasks/models/workflow-action-object.model';
import { MetadataSchemaDataService } from './data/metadata-schema-data.service';
import { MetadataFieldDataService } from './data/metadata-field-data.service';
+import { TokenResponseParsingService } from './auth/token-response-parsing.service';
/**
* When not in production, endpoint responses can be mocked for testing purposes
@@ -265,6 +266,7 @@ const PROVIDERS = [
ProcessFilesResponseParsingService,
MetadataSchemaDataService,
MetadataFieldDataService,
+ TokenResponseParsingService,
// register AuthInterceptor as HttpInterceptor
{
provide: HTTP_INTERCEPTORS,
diff --git a/src/app/core/data/request.models.ts b/src/app/core/data/request.models.ts
index 2c4d7d362c..bd497d4ddb 100644
--- a/src/app/core/data/request.models.ts
+++ b/src/app/core/data/request.models.ts
@@ -19,6 +19,7 @@ import { TaskResponseParsingService } from '../tasks/task-response-parsing.servi
import { ContentSourceResponseParsingService } from './content-source-response-parsing.service';
import { MappedCollectionsReponseParsingService } from './mapped-collections-reponse-parsing.service';
import { ProcessFilesResponseParsingService } from './process-files-response-parsing.service';
+import { TokenResponseParsingService } from '../auth/token-response-parsing.service';
/* tslint:disable:max-classes-per-file */
@@ -266,6 +267,15 @@ export class AuthGetRequest extends GetRequest {
}
}
+/**
+ * A POST request for retrieving a token
+ */
+export class TokenPostRequest extends PostRequest {
+ getResponseParser(): GenericConstructor {
+ return TokenResponseParsingService;
+ }
+}
+
export class IntegrationRequest extends GetRequest {
constructor(uuid: string, href: string) {
super(uuid, href);
diff --git a/src/app/core/shared/file.service.ts b/src/app/core/shared/file.service.ts
index 7e89a4e5dd..ca0a409b2d 100644
--- a/src/app/core/shared/file.service.ts
+++ b/src/app/core/shared/file.service.ts
@@ -1,10 +1,10 @@
-import { Injectable } from '@angular/core';
-import { HttpHeaders } from '@angular/common/http';
-
-import { DSpaceRESTv2Service, HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service';
-import { RestRequestMethod } from '../data/rest-request-method';
-import { saveAs } from 'file-saver';
+import { Inject, Injectable } from '@angular/core';
import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model';
+import { AuthService } from '../auth/auth.service';
+import { take } from 'rxjs/operators';
+import { NativeWindowRef, NativeWindowService } from '../services/window.service';
+import { URLCombiner } from '../url-combiner/url-combiner';
+import { hasValue } from '../../shared/empty.util';
/**
* Provides utility methods to save files on the client-side.
@@ -12,22 +12,20 @@ import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.
@Injectable()
export class FileService {
constructor(
- private restService: DSpaceRESTv2Service
+ @Inject(NativeWindowService) protected _window: NativeWindowRef,
+ private authService: AuthService
) { }
/**
- * Makes a HTTP Get request to download a file
+ * Combines an URL with a short-lived token and sets the current URL to the newly created one
*
* @param url
* file url
*/
downloadFile(url: string) {
- const headers = new HttpHeaders();
- const options: HttpOptions = Object.create({headers, responseType: 'blob'});
- return this.restService.request(RestRequestMethod.GET, url, null, options)
- .subscribe((data) => {
- saveAs(data.payload as Blob, this.getFileNameFromResponseContentDisposition(data));
- });
+ this.authService.getShortlivedToken().pipe(take(1)).subscribe((token) => {
+ this._window.nativeWindow.location.href = hasValue(token) ? new URLCombiner(url, `?authentication-token=${token}`).toString() : url;
+ });
}
/**
diff --git a/src/app/shared/file-download-link/file-download-link.component.html b/src/app/shared/file-download-link/file-download-link.component.html
new file mode 100644
index 0000000000..06624c8b40
--- /dev/null
+++ b/src/app/shared/file-download-link/file-download-link.component.html
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/src/app/shared/file-download-link/file-download-link.component.scss b/src/app/shared/file-download-link/file-download-link.component.scss
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/src/app/shared/file-download-link/file-download-link.component.spec.ts b/src/app/shared/file-download-link/file-download-link.component.spec.ts
new file mode 100644
index 0000000000..ac1751d43d
--- /dev/null
+++ b/src/app/shared/file-download-link/file-download-link.component.spec.ts
@@ -0,0 +1,57 @@
+import { async, ComponentFixture, TestBed } from '@angular/core/testing';
+import { FileDownloadLinkComponent } from './file-download-link.component';
+import { AuthService } from '../../core/auth/auth.service';
+import { FileService } from '../../core/shared/file.service';
+import { of as observableOf } from 'rxjs';
+
+describe('FileDownloadLinkComponent', () => {
+ let component: FileDownloadLinkComponent;
+ let fixture: ComponentFixture;
+
+ let authService: AuthService;
+ let fileService: FileService;
+ let href: string;
+
+ function init() {
+ authService = jasmine.createSpyObj('authService', {
+ isAuthenticated: observableOf(true)
+ });
+ fileService = jasmine.createSpyObj('fileService', ['downloadFile']);
+ href = 'test-download-file-link';
+ }
+
+ beforeEach(async(() => {
+ init();
+ TestBed.configureTestingModule({
+ declarations: [ FileDownloadLinkComponent ],
+ providers: [
+ { provide: AuthService, useValue: authService },
+ { provide: FileService, useValue: fileService }
+ ]
+ })
+ .compileComponents();
+ }));
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(FileDownloadLinkComponent);
+ component = fixture.componentInstance;
+ component.href = href;
+ fixture.detectChanges();
+ });
+
+ describe('downloadFile', () => {
+ let result;
+
+ beforeEach(() => {
+ result = component.downloadFile();
+ });
+
+ it('should call fileService.downloadFile with the provided href', () => {
+ expect(fileService.downloadFile).toHaveBeenCalledWith(href);
+ });
+
+ it('should return false', () => {
+ expect(result).toEqual(false);
+ });
+ });
+});
diff --git a/src/app/shared/file-download-link/file-download-link.component.ts b/src/app/shared/file-download-link/file-download-link.component.ts
new file mode 100644
index 0000000000..9df7c191ff
--- /dev/null
+++ b/src/app/shared/file-download-link/file-download-link.component.ts
@@ -0,0 +1,48 @@
+import { Component, Input, OnInit } from '@angular/core';
+import { FileService } from '../../core/shared/file.service';
+import { Observable } from 'rxjs/internal/Observable';
+import { AuthService } from '../../core/auth/auth.service';
+
+@Component({
+ selector: 'ds-file-download-link',
+ templateUrl: './file-download-link.component.html',
+ styleUrls: ['./file-download-link.component.scss']
+})
+/**
+ * Component displaying a download link
+ * When the user is authenticated, a short-lived token retrieved from the REST API is added to the download link,
+ * ensuring the user is authorized to download the file.
+ */
+export class FileDownloadLinkComponent implements OnInit {
+ /**
+ * Href to link to
+ */
+ @Input() href: string;
+
+ /**
+ * Optional file name for the download
+ */
+ @Input() download: string;
+
+ /**
+ * Whether or not the current user is authenticated
+ */
+ isAuthenticated$: Observable;
+
+ constructor(private fileService: FileService,
+ private authService: AuthService) { }
+
+ ngOnInit() {
+ this.isAuthenticated$ = this.authService.isAuthenticated();
+ }
+
+ /**
+ * Start a download of the file
+ * Return false to ensure the original href is displayed when the user hovers over the link
+ */
+ downloadFile(): boolean {
+ this.fileService.downloadFile(this.href);
+ return false;
+ }
+
+}
diff --git a/src/app/shared/shared.module.ts b/src/app/shared/shared.module.ts
index f8c2309dcc..98526bf2bc 100644
--- a/src/app/shared/shared.module.ts
+++ b/src/app/shared/shared.module.ts
@@ -204,6 +204,7 @@ import { ResourcePolicyTargetResolver } from './resource-policies/resolvers/reso
import { ResourcePolicyResolver } from './resource-policies/resolvers/resource-policy.resolver';
import { EpersonSearchBoxComponent } from './resource-policies/form/eperson-group-list/eperson-search-box/eperson-search-box.component';
import { GroupSearchBoxComponent } from './resource-policies/form/eperson-group-list/group-search-box/group-search-box.component';
+import { FileDownloadLinkComponent } from './file-download-link/file-download-link.component';
import { CollectionDropdownComponent } from './collection-dropdown/collection-dropdown.component';
const MODULES = [
@@ -390,6 +391,7 @@ const COMPONENTS = [
EpersonGroupListComponent,
EpersonSearchBoxComponent,
GroupSearchBoxComponent,
+ FileDownloadLinkComponent,
CollectionDropdownComponent
];
@@ -463,7 +465,8 @@ const ENTRY_COMPONENTS = [
ClaimedTaskActionsApproveComponent,
ClaimedTaskActionsRejectComponent,
ClaimedTaskActionsReturnToPoolComponent,
- ClaimedTaskActionsEditMetadataComponent
+ ClaimedTaskActionsEditMetadataComponent,
+ FileDownloadLinkComponent,
];
const SHARED_ITEM_PAGE_COMPONENTS = [
diff --git a/src/app/shared/testing/auth-request-service.stub.ts b/src/app/shared/testing/auth-request-service.stub.ts
index 1dc04380df..671c9237bf 100644
--- a/src/app/shared/testing/auth-request-service.stub.ts
+++ b/src/app/shared/testing/auth-request-service.stub.ts
@@ -9,6 +9,7 @@ import { EPersonMock } from './eperson.mock';
export class AuthRequestServiceStub {
protected mockUser: EPerson = EPersonMock;
protected mockTokenInfo = new AuthTokenInfo('test_token');
+ protected mockShortLivedToken = 'test-shortlived-token';
public postToEndpoint(method: string, body: any, options?: HttpOptions): Observable {
const authStatusStub: AuthStatus = new AuthStatus();
@@ -82,4 +83,8 @@ export class AuthRequestServiceStub {
}
return obj;
}
+
+ public getShortlivedToken() {
+ return observableOf(this.mockShortLivedToken);
+ }
}