Merge branch 'main' into w2p-73014_Metadata-edit-patch-error-fix

Conflicts:
	src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.ts
This commit is contained in:
Kristof De Langhe
2020-09-24 10:16:01 +02:00
261 changed files with 9049 additions and 2814 deletions

21
.codecov.yml Normal file
View File

@@ -0,0 +1,21 @@
# DSpace configuration for Codecov.io coverage reports
# These override the default YAML settings at
# https://docs.codecov.io/docs/codecov-yaml#section-default-yaml
# Can be validated via instructions at:
# https://docs.codecov.io/docs/codecov-yaml#validate-your-repository-yaml
# Settings related to code coverage analysis
coverage:
status:
project:
default:
# For each PR, auto compare coverage to previous commit.
# Require that overall (project) coverage does NOT drop more than 0.5%
target: auto
threshold: 0.5%
# Turn PR comments "off". This feature adds the code coverage summary as a
# comment on each PR. See https://docs.codecov.io/docs/pull-request-comments
# However, this same info is available from the Codecov checks in the PR's
# "Checks" tab in GitHub. So, the comment is unnecessary.
comment: false

View File

@@ -60,7 +60,7 @@ after_script:
# Shutdown docker after everything runs
- docker-compose -f ./docker/docker-compose-travis.yml down
# After a successful build and test (see 'script'), send code coverage reports to coveralls.io
# These code coverage reports are generated by the coveralls node module in our package.json
# After a successful build and test (see 'script'), send code coverage reports to codecov.io
# These code coverage reports are generated by the codecov node module in our package.json
after_success:
- cat coverage/dspace-angular/lcov.info | ./node_modules/coveralls/bin/coveralls.js
- codecov

View File

@@ -1,4 +1,4 @@
[![Build Status](https://travis-ci.com/DSpace/dspace-angular.svg?branch=main)](https://travis-ci.com/DSpace/dspace-angular) [![Coverage Status](https://coveralls.io/repos/github/DSpace/dspace-angular/badge.svg?branch=main)](https://coveralls.io/github/DSpace/dspace-angular?branch=main) [![Universal Angular](https://img.shields.io/badge/universal-angular2-brightgreen.svg?style=flat)](https://github.com/angular/universal)
[![Build Status](https://travis-ci.com/DSpace/dspace-angular.svg?branch=main)](https://travis-ci.com/DSpace/dspace-angular) [![Coverage Status](https://codecov.io/gh/DSpace/dspace-angular/branch/main/graph/badge.svg)](https://codecov.io/gh/DSpace/dspace-angular) [![Universal Angular](https://img.shields.io/badge/universal-angular2-brightgreen.svg?style=flat)](https://github.com/angular/universal)
dspace-angular
==============

View File

@@ -97,6 +97,7 @@
"json5": "^2.1.0",
"jsonschema": "1.2.2",
"jwt-decode": "^2.2.0",
"klaro": "^0.6.3",
"moment": "^2.22.1",
"morgan": "^1.9.1",
"ng-mocks": "^8.1.0",
@@ -136,10 +137,10 @@
"@types/js-cookie": "2.1.0",
"@types/lodash": "^4.14.110",
"@types/node": "11.15.3",
"codecov": "^3.7.2",
"codelyzer": "^5.0.0",
"compression-webpack-plugin": "^3.0.1",
"copy-webpack-plugin": "^5.1.1",
"coveralls": "^3.0.0",
"css-loader": "3.4.0",
"cssnano": "^4.1.10",
"deep-freeze": "0.0.1",

View File

@@ -15,7 +15,6 @@
* import for `ngExpressEngine`.
*/
import 'zone.js/dist/zone-node';
import 'reflect-metadata';
import 'rxjs';
@@ -34,6 +33,7 @@ import { enableProdMode, NgModuleFactory, Type } from '@angular/core';
import { REQUEST, RESPONSE } from '@nguniversal/express-engine/tokens';
import { environment } from './src/environments/environment';
import { createProxyMiddleware } from 'http-proxy-middleware';
import { hasValue, hasNoValue } from './src/app/shared/empty.util';
/*
* Set path for the browser application's dist folder
@@ -99,7 +99,6 @@ app.engine('html', (_, options, callback) =>
/*
* Register the view engines for html and ejs
*/
app.set('view engine', 'ejs');
app.set('view engine', 'html');
/*
@@ -131,56 +130,31 @@ app.get('*.*', cacheControl, express.static(DIST_FOLDER, { index: false }));
* The callback function to serve server side angular
*/
function ngApp(req, res) {
// Object to be set to window.dspace when CSR is used
// this allows us to pass the info in the original request
// to the dspace7-angular instance running in the client's browser
const dspace = {
originalRequest: {
headers: req.headers,
body: req.body,
method: req.method,
params: req.params,
reportProgress: req.reportProgress,
withCredentials: req.withCredentials,
responseType: req.responseType,
urlWithParams: req.urlWithParams
}
};
// callback function for the case when SSR throws an error.
function onHandleError(parentZoneDelegate, currentZone, targetZone, error) {
if (!res._headerSent) {
console.warn('Error in SSR, serving for direct CSR. Error details : ', error);
res.sendFile('index.csr.ejs', {
root: DIST_FOLDER,
scripts: `<script>window.dspace = ${JSON.stringify(dspace)}</script>`
});
}
}
if (environment.universal.preboot) {
// If preboot is enabled, create a new zone for SSR, and
// register the error handler for when it throws an error
Zone.current.fork({ name: 'CSR fallback', onHandleError }).run(() => {
res.render(DIST_FOLDER + '/index.html', {
req,
res,
preboot: environment.universal.preboot,
async: environment.universal.async,
time: environment.universal.time,
baseUrl: environment.ui.nameSpace,
originUrl: environment.ui.baseUrl,
requestUrl: req.originalUrl
});
});
res.render(DIST_FOLDER + '/index.html', {
req,
res,
preboot: environment.universal.preboot,
async: environment.universal.async,
time: environment.universal.time,
baseUrl: environment.ui.nameSpace,
originUrl: environment.ui.baseUrl,
requestUrl: req.originalUrl
}, (err, data) => {
if (hasNoValue(err) && hasValue(data)) {
res.send(data);
} else {
console.warn('Error in SSR, serving for direct CSR.');
if (hasValue(err)) {
console.warn('Error details : ', err);
}
res.sendFile(DIST_FOLDER + '/index.html');
}
})
} else {
// If preboot is disabled, just serve the client side ejs template and pass it the required
// variables
// If preboot is disabled, just serve the client
console.log('Universal off, serving for direct CSR');
res.render('index-csr.ejs', {
root: DIST_FOLDER,
scripts: `<script>window.dspace = ${JSON.stringify(dspace)}</script>`
});
res.sendFile(DIST_FOLDER + '/index.html');
}
}

View File

@@ -0,0 +1,30 @@
import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router';
import { Collection } from '../core/shared/collection.model';
import { CollectionPageResolver } from './collection-page.resolver';
import { AuthorizationDataService } from '../core/data/feature-authorization/authorization-data.service';
import { of as observableOf } from 'rxjs';
import { DsoPageFeatureGuard } from '../core/data/feature-authorization/feature-authorization-guard/dso-page-feature.guard';
import { Observable } from 'rxjs/internal/Observable';
import { FeatureID } from '../core/data/feature-authorization/feature-id';
@Injectable({
providedIn: 'root'
})
/**
* Guard for preventing unauthorized access to certain {@link Collection} pages requiring administrator rights
*/
export class CollectionPageAdministratorGuard extends DsoPageFeatureGuard<Collection> {
constructor(protected resolver: CollectionPageResolver,
protected authorizationService: AuthorizationDataService,
protected router: Router) {
super(resolver, authorizationService, router);
}
/**
* Check administrator authorization rights
*/
getFeatureID(next: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<FeatureID> {
return observableOf(FeatureID.AdministratorOf);
}
}

View File

@@ -19,6 +19,7 @@ import {
COLLECTION_EDIT_PATH,
COLLECTION_CREATE_PATH
} from './collection-page-routing-paths';
import { CollectionPageAdministratorGuard } from './collection-page-administrator.guard';
@NgModule({
imports: [
@@ -39,7 +40,7 @@ import {
{
path: COLLECTION_EDIT_PATH,
loadChildren: './edit-collection-page/edit-collection-page.module#EditCollectionPageModule',
canActivate: [AuthenticatedGuard]
canActivate: [CollectionPageAdministratorGuard]
},
{
path: 'delete',
@@ -78,7 +79,8 @@ import {
CollectionBreadcrumbResolver,
DSOBreadcrumbsService,
LinkService,
CreateCollectionPageGuard
CreateCollectionPageGuard,
CollectionPageAdministratorGuard
]
})
export class CollectionPageRoutingModule {

View File

@@ -0,0 +1,30 @@
import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router';
import { Community } from '../core/shared/community.model';
import { CommunityPageResolver } from './community-page.resolver';
import { AuthorizationDataService } from '../core/data/feature-authorization/authorization-data.service';
import { of as observableOf } from 'rxjs';
import { DsoPageFeatureGuard } from '../core/data/feature-authorization/feature-authorization-guard/dso-page-feature.guard';
import { Observable } from 'rxjs/internal/Observable';
import { FeatureID } from '../core/data/feature-authorization/feature-id';
@Injectable({
providedIn: 'root'
})
/**
* Guard for preventing unauthorized access to certain {@link Community} pages requiring administrator rights
*/
export class CommunityPageAdministratorGuard extends DsoPageFeatureGuard<Community> {
constructor(protected resolver: CommunityPageResolver,
protected authorizationService: AuthorizationDataService,
protected router: Router) {
super(resolver, authorizationService, router);
}
/**
* Check administrator authorization rights
*/
getFeatureID(next: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<FeatureID> {
return observableOf(FeatureID.AdministratorOf);
}
}

View File

@@ -11,6 +11,7 @@ import { CommunityBreadcrumbResolver } from '../core/breadcrumbs/community-bread
import { DSOBreadcrumbsService } from '../core/breadcrumbs/dso-breadcrumbs.service';
import { LinkService } from '../core/cache/builders/link.service';
import { COMMUNITY_EDIT_PATH, COMMUNITY_CREATE_PATH } from './community-page-routing-paths';
import { CommunityPageAdministratorGuard } from './community-page-administrator.guard';
@NgModule({
imports: [
@@ -31,7 +32,7 @@ import { COMMUNITY_EDIT_PATH, COMMUNITY_CREATE_PATH } from './community-page-rou
{
path: COMMUNITY_EDIT_PATH,
loadChildren: './edit-community-page/edit-community-page.module#EditCommunityPageModule',
canActivate: [AuthenticatedGuard]
canActivate: [CommunityPageAdministratorGuard]
},
{
path: 'delete',
@@ -53,7 +54,8 @@ import { COMMUNITY_EDIT_PATH, COMMUNITY_CREATE_PATH } from './community-page-rou
CommunityBreadcrumbResolver,
DSOBreadcrumbsService,
LinkService,
CreateCommunityPageGuard
CreateCommunityPageGuard,
CommunityPageAdministratorGuard
]
})
export class CommunityPageRoutingModule {

View File

@@ -123,7 +123,7 @@ export class AbstractItemUpdateComponent extends AbstractTrackableComponent impl
/**
* Check if the current page is entirely valid
*/
protected isValid() {
public isValid() {
return this.objectUpdatesService.isValidPage(this.url);
}

View File

@@ -29,6 +29,8 @@ import {
ITEM_EDIT_REINSTATE_PATH,
ITEM_EDIT_WITHDRAW_PATH
} from './edit-item-page.routing-paths';
import { ItemPageReinstateGuard } from './item-page-reinstate.guard';
import { ItemPageWithdrawGuard } from './item-page-withdraw.guard';
/**
* Routing module that handles the routing for the Edit Item page administrator functionality
@@ -98,10 +100,12 @@ import {
{
path: ITEM_EDIT_WITHDRAW_PATH,
component: ItemWithdrawComponent,
canActivate: [ItemPageWithdrawGuard]
},
{
path: ITEM_EDIT_REINSTATE_PATH,
component: ItemReinstateComponent,
canActivate: [ItemPageReinstateGuard]
},
{
path: ITEM_EDIT_PRIVATE_PATH,
@@ -154,7 +158,9 @@ import {
I18nBreadcrumbResolver,
I18nBreadcrumbsService,
ResourcePolicyResolver,
ResourcePolicyTargetResolver
ResourcePolicyTargetResolver,
ItemPageReinstateGuard,
ItemPageWithdrawGuard
]
})
export class EditItemPageRoutingModule {

View File

@@ -5,14 +5,15 @@
</div>
<div *ngIf="(editable | async)" class="field-container">
<ds-filter-input-suggestions [suggestions]="(metadataFieldSuggestions | async)"
[(ngModel)]="metadata.key"
[(ngModel)]="metadata.key"
[url]="this.url"
[metadata]="this.metadata"
(submitSuggestion)="update(suggestionControl)"
(clickSuggestion)="update(suggestionControl)"
(typeSuggestion)="update(suggestionControl)"
(dsClickOutside)="checkValidity(suggestionControl)"
(findSuggestions)="findMetadataFieldSuggestions($event)"
#suggestionControl="ngModel"
[dsInListValidator]="metadataFields"
[valid]="(valid | async) !== false"
dsAutoFocus autoFocusSelector=".suggestion_input"
[ngModelOptions]="{standalone: true}"
@@ -46,12 +47,12 @@
</td>
<td class="text-center">
<div class="btn-group edit-field">
<button [disabled]="!(canSetEditable() | async)" *ngIf="!(editable | async)"
<button [disabled]="!(canSetEditable() | async)" *ngIf="!(editable | async)"
(click)="setEditable(true)" class="btn btn-outline-primary btn-sm"
title="{{'item.edit.metadata.edit.buttons.edit' | translate}}">
<i class="fas fa-edit fa-fw"></i>
</button>
<button [disabled]="!(canSetUneditable() | async)" *ngIf="(editable | async)"
<button [disabled]="!(canSetUneditable() | async) || (valid | async) === false" *ngIf="(editable | async)"
(click)="setEditable(false)" class="btn btn-outline-success btn-sm"
title="{{'item.edit.metadata.edit.buttons.unedit' | translate}}">
<i class="fas fa-check fa-fw"></i>

View File

@@ -1,11 +1,12 @@
import { CUSTOM_ELEMENTS_SCHEMA, DebugElement } from '@angular/core';
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { async, ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing';
import { FormsModule } from '@angular/forms';
import { By } from '@angular/platform-browser';
import { TranslateModule } from '@ngx-translate/core';
import { getTestScheduler } from 'jasmine-marbles';
import { of as observableOf } from 'rxjs';
import { TestScheduler } from 'rxjs/testing';
import { MetadataFieldDataService } from '../../../../core/data/metadata-field-data.service';
import { FieldChangeType } from '../../../../core/data/object-updates/object-updates.actions';
import { ObjectUpdatesService } from '../../../../core/data/object-updates/object-updates.service';
import { PaginatedList } from '../../../../core/data/paginated-list';
@@ -14,9 +15,14 @@ import { MetadataSchema } from '../../../../core/metadata/metadata-schema.model'
import { RegistryService } from '../../../../core/registry/registry.service';
import { MetadatumViewModel } from '../../../../core/shared/metadata.models';
import { InputSuggestion } from '../../../../shared/input-suggestions/input-suggestions.model';
import { SharedModule } from '../../../../shared/shared.module';
import { createSuccessfulRemoteDataObject$ } from '../../../../shared/remote-data.utils';
import {
createSuccessfulRemoteDataObject$
} from '../../../../shared/remote-data.utils';
import { followLink } from '../../../../shared/utils/follow-link-config.model';
import { EditInPlaceFieldComponent } from './edit-in-place-field.component';
import { FilterInputSuggestionsComponent } from '../../../../shared/input-suggestions/filter-suggestions/filter-input-suggestions.component';
import { MockComponent, MockDirective } from 'ng-mocks';
import { DebounceDirective } from '../../../../shared/utils/debounce.directive';
let comp: EditInPlaceFieldComponent;
let fixture: ComponentFixture<EditInPlaceFieldComponent>;
@@ -25,17 +31,21 @@ let el: HTMLElement;
let metadataFieldService;
let objectUpdatesService;
let paginatedMetadataFields;
const mdSchema = Object.assign(new MetadataSchema(), { prefix: 'dc' })
const mdSchema = Object.assign(new MetadataSchema(), { prefix: 'dc' });
const mdSchemaRD$ = createSuccessfulRemoteDataObject$(mdSchema);
const mdField1 = Object.assign(new MetadataField(), {
schema: mdSchema,
schema: mdSchemaRD$,
element: 'contributor',
qualifier: 'author'
});
const mdField2 = Object.assign(new MetadataField(), { schema: mdSchema, element: 'title' });
const mdField2 = Object.assign(new MetadataField(), {
schema: mdSchemaRD$,
element: 'title'
});
const mdField3 = Object.assign(new MetadataField(), {
schema: mdSchema,
schema: mdSchemaRD$,
element: 'description',
qualifier: 'abstract'
qualifier: 'abstract',
});
const metadatum = Object.assign(new MetadatumViewModel(), {
@@ -74,11 +84,16 @@ describe('EditInPlaceFieldComponent', () => {
);
TestBed.configureTestingModule({
imports: [FormsModule, SharedModule, TranslateModule.forRoot()],
declarations: [EditInPlaceFieldComponent],
imports: [FormsModule, TranslateModule.forRoot()],
declarations: [
EditInPlaceFieldComponent,
MockDirective(DebounceDirective),
MockComponent(FilterInputSuggestionsComponent)
],
providers: [
{ provide: RegistryService, useValue: metadataFieldService },
{ provide: ObjectUpdatesService, useValue: objectUpdatesService },
{ provide: MetadataFieldDataService, useValue: {} }
], schemas: [
CUSTOM_ELEMENTS_SCHEMA
]
@@ -94,13 +109,12 @@ describe('EditInPlaceFieldComponent', () => {
comp.url = url;
comp.fieldUpdate = fieldUpdate;
comp.metadata = metadatum;
fixture.detectChanges();
});
describe('update', () => {
beforeEach(() => {
comp.update();
fixture.detectChanges();
});
it('it should call saveChangeFieldUpdate on the objectUpdatesService with the correct url and metadata', () => {
@@ -112,6 +126,7 @@ describe('EditInPlaceFieldComponent', () => {
const editable = false;
beforeEach(() => {
comp.setEditable(editable);
fixture.detectChanges();
});
it('it should call setEditableFieldUpdate on the objectUpdatesService with the correct url and uuid and false', () => {
@@ -121,7 +136,7 @@ describe('EditInPlaceFieldComponent', () => {
describe('editable is true', () => {
beforeEach(() => {
comp.editable = observableOf(true);
objectUpdatesService.isEditable.and.returnValue(observableOf(true));
fixture.detectChanges();
});
it('the div should contain input fields or textareas', () => {
@@ -133,7 +148,7 @@ describe('EditInPlaceFieldComponent', () => {
describe('editable is false', () => {
beforeEach(() => {
comp.editable = observableOf(false);
objectUpdatesService.isEditable.and.returnValue(observableOf(false));
fixture.detectChanges();
});
it('the div should contain no input fields or textareas', () => {
@@ -145,7 +160,7 @@ describe('EditInPlaceFieldComponent', () => {
describe('isValid is true', () => {
beforeEach(() => {
comp.valid = observableOf(true);
objectUpdatesService.isValid.and.returnValue(observableOf(true));
fixture.detectChanges();
});
it('the div should not contain an error message', () => {
@@ -157,10 +172,10 @@ describe('EditInPlaceFieldComponent', () => {
describe('isValid is false', () => {
beforeEach(() => {
comp.valid = observableOf(false);
objectUpdatesService.isValid.and.returnValue(observableOf(false));
fixture.detectChanges();
});
it('the div should contain no input fields or textareas', () => {
it('there should be an error message', () => {
const errorMessages = de.queryAll(By.css('small.text-danger'));
expect(errorMessages.length).toBeGreaterThan(0);
@@ -170,6 +185,7 @@ describe('EditInPlaceFieldComponent', () => {
describe('remove', () => {
beforeEach(() => {
comp.remove();
fixture.detectChanges();
});
it('it should call saveRemoveFieldUpdate on the objectUpdatesService with the correct url and metadata', () => {
@@ -180,6 +196,7 @@ describe('EditInPlaceFieldComponent', () => {
describe('removeChangesFromField', () => {
beforeEach(() => {
comp.removeChangesFromField();
fixture.detectChanges();
});
it('it should call removeChangesFromField on the objectUpdatesService with the correct url and uuid', () => {
@@ -192,19 +209,19 @@ describe('EditInPlaceFieldComponent', () => {
const metadataFieldSuggestions: InputSuggestion[] =
[
{ displayValue: mdField1.toString().split('.').join('.&#8203;'), value: mdField1.toString() },
{ displayValue: mdField2.toString().split('.').join('.&#8203;'), value: mdField2.toString() },
{ displayValue: mdField3.toString().split('.').join('.&#8203;'), value: mdField3.toString() }
{ displayValue: ('dc.' + mdField1.toString()).split('.').join('.&#8203;'), value: ('dc.' + mdField1.toString()) },
{ displayValue: ('dc.' + mdField2.toString()).split('.').join('.&#8203;'), value: ('dc.' + mdField2.toString()) },
{ displayValue: ('dc.' + mdField3.toString()).split('.').join('.&#8203;'), value: ('dc.' + mdField3.toString()) }
];
beforeEach(() => {
beforeEach(fakeAsync(() => {
comp.findMetadataFieldSuggestions(query);
});
tick();
fixture.detectChanges();
}));
it('it should call queryMetadataFields on the metadataFieldService with the correct query', () => {
expect(metadataFieldService.queryMetadataFields).toHaveBeenCalledWith(query);
expect(metadataFieldService.queryMetadataFields).toHaveBeenCalledWith(query, null, followLink('schema'));
});
it('it should set metadataFieldSuggestions to the right value', () => {
@@ -216,7 +233,8 @@ describe('EditInPlaceFieldComponent', () => {
describe('canSetEditable', () => {
describe('when editable is currently true', () => {
beforeEach(() => {
comp.editable = observableOf(true);
objectUpdatesService.isEditable.and.returnValue(observableOf(true));
fixture.detectChanges();
});
it('canSetEditable should return an observable emitting false', () => {
@@ -227,12 +245,14 @@ describe('EditInPlaceFieldComponent', () => {
describe('when editable is currently false', () => {
beforeEach(() => {
comp.editable = observableOf(false);
objectUpdatesService.isEditable.and.returnValue(observableOf(false));
fixture.detectChanges();
});
describe('when the fieldUpdate\'s changeType is currently not REMOVE', () => {
beforeEach(() => {
comp.fieldUpdate.changeType = FieldChangeType.ADD;
fixture.detectChanges();
});
it('canSetEditable should return an observable emitting true', () => {
const expected = '(a|)';
@@ -243,6 +263,7 @@ describe('EditInPlaceFieldComponent', () => {
describe('when the fieldUpdate\'s changeType is currently REMOVE', () => {
beforeEach(() => {
comp.fieldUpdate.changeType = FieldChangeType.REMOVE;
fixture.detectChanges();
});
it('canSetEditable should return an observable emitting false', () => {
const expected = '(a|)';
@@ -255,7 +276,8 @@ describe('EditInPlaceFieldComponent', () => {
describe('canSetUneditable', () => {
describe('when editable is currently true', () => {
beforeEach(() => {
comp.editable = observableOf(true);
objectUpdatesService.isEditable.and.returnValue(observableOf(true));
fixture.detectChanges();
});
it('canSetUneditable should return an observable emitting true', () => {
@@ -266,7 +288,8 @@ describe('EditInPlaceFieldComponent', () => {
describe('when editable is currently false', () => {
beforeEach(() => {
comp.editable = observableOf(false);
objectUpdatesService.isEditable.and.returnValue(observableOf(false));
fixture.detectChanges();
});
it('canSetUneditable should return an observable emitting false', () => {
@@ -278,7 +301,7 @@ describe('EditInPlaceFieldComponent', () => {
describe('when canSetEditable emits true', () => {
beforeEach(() => {
comp.editable = observableOf(false);
objectUpdatesService.isEditable.and.returnValue(observableOf(false));
spyOn(comp, 'canSetEditable').and.returnValue(observableOf(true));
fixture.detectChanges();
});
@@ -290,7 +313,7 @@ describe('EditInPlaceFieldComponent', () => {
describe('when canSetEditable emits false', () => {
beforeEach(() => {
comp.editable = observableOf(false);
objectUpdatesService.isEditable.and.returnValue(observableOf(false));
spyOn(comp, 'canSetEditable').and.returnValue(observableOf(false));
fixture.detectChanges();
});
@@ -302,7 +325,7 @@ describe('EditInPlaceFieldComponent', () => {
describe('when canSetUneditable emits true', () => {
beforeEach(() => {
comp.editable = observableOf(true);
objectUpdatesService.isEditable.and.returnValue(observableOf(true));
spyOn(comp, 'canSetUneditable').and.returnValue(observableOf(true));
fixture.detectChanges();
});
@@ -314,7 +337,7 @@ describe('EditInPlaceFieldComponent', () => {
describe('when canSetUneditable emits false', () => {
beforeEach(() => {
comp.editable = observableOf(true);
objectUpdatesService.isEditable.and.returnValue(observableOf(true));
spyOn(comp, 'canSetUneditable').and.returnValue(observableOf(false));
fixture.detectChanges();
});
@@ -372,6 +395,7 @@ describe('EditInPlaceFieldComponent', () => {
describe('when the fieldUpdate\'s changeType is currently not REMOVE or ADD', () => {
beforeEach(() => {
comp.fieldUpdate.changeType = FieldChangeType.UPDATE;
fixture.detectChanges();
});
it('canRemove should return an observable emitting true', () => {
const expected = '(a|)';
@@ -382,6 +406,7 @@ describe('EditInPlaceFieldComponent', () => {
describe('when the fieldUpdate\'s changeType is currently ADD', () => {
beforeEach(() => {
comp.fieldUpdate.changeType = FieldChangeType.ADD;
fixture.detectChanges();
});
it('canRemove should return an observable emitting false', () => {
const expected = '(a|)';
@@ -394,7 +419,7 @@ describe('EditInPlaceFieldComponent', () => {
describe('when editable is currently true', () => {
beforeEach(() => {
comp.editable = observableOf(true);
objectUpdatesService.isEditable.and.returnValue(observableOf(true));
comp.fieldUpdate.changeType = undefined;
fixture.detectChanges();
});
@@ -408,6 +433,7 @@ describe('EditInPlaceFieldComponent', () => {
describe('when the fieldUpdate\'s changeType is currently ADD, UPDATE or REMOVE', () => {
beforeEach(() => {
comp.fieldUpdate.changeType = FieldChangeType.ADD;
fixture.detectChanges();
});
it('canUndo should return an observable emitting true', () => {
@@ -419,6 +445,7 @@ describe('EditInPlaceFieldComponent', () => {
describe('when the fieldUpdate\'s changeType is currently undefined', () => {
beforeEach(() => {
comp.fieldUpdate.changeType = undefined;
fixture.detectChanges();
});
it('canUndo should return an observable emitting false', () => {

View File

@@ -1,4 +1,5 @@
import { Component, Input, OnChanges, OnInit } from '@angular/core';
import { metadataFieldsToString } from '../../../../core/shared/operators';
import { hasValue, isNotEmpty } from '../../../../shared/empty.util';
import { RegistryService } from '../../../../core/registry/registry.service';
import { cloneDeep } from 'lodash';
@@ -9,8 +10,8 @@ import { FieldUpdate } from '../../../../core/data/object-updates/object-updates
import { ObjectUpdatesService } from '../../../../core/data/object-updates/object-updates.service';
import { NgModel } from '@angular/forms';
import { MetadatumViewModel } from '../../../../core/shared/metadata.models';
import { MetadataField } from '../../../../core/metadata/metadata-field.model';
import { InputSuggestion } from '../../../../shared/input-suggestions/input-suggestions.model';
import { followLink } from '../../../../shared/utils/follow-link-config.model';
@Component({
// tslint:disable-next-line:component-selector
@@ -32,15 +33,10 @@ export class EditInPlaceFieldComponent implements OnInit, OnChanges {
*/
@Input() url: string;
/**
* List of strings with all metadata field keys available
*/
@Input() metadataFields: string[];
/**
* The metadatum of this field
*/
metadata: MetadatumViewModel;
@Input() metadata: MetadatumViewModel;
/**
* Emits whether or not this field is currently editable
@@ -126,27 +122,34 @@ export class EditInPlaceFieldComponent implements OnInit, OnChanges {
* Ignores fields from metadata schemas "relation" and "relationship"
* @param query The query to look for
*/
findMetadataFieldSuggestions(query: string): void {
findMetadataFieldSuggestions(query: string) {
if (isNotEmpty(query)) {
this.registryService.queryMetadataFields(query).pipe(
// getSucceededRemoteData(),
take(1),
map((data) => data.payload.page)
).subscribe(
(fields: MetadataField[]) => this.metadataFieldSuggestions.next(
fields.map((field: MetadataField) => {
return {
displayValue: field.toString().split('.').join('.&#8203;'),
value: field.toString()
};
})
)
);
return this.registryService.queryMetadataFields(query, null, followLink('schema')).pipe(
metadataFieldsToString(),
take(1))
.subscribe((fieldNames: string[]) => {
this.setInputSuggestions(fieldNames);
})
} else {
this.metadataFieldSuggestions.next([]);
}
}
/**
* Set the list of input suggestion with the given Metadata fields, which all require a resolved MetadataSchema
* @param fields list of Metadata fields, which all require a resolved MetadataSchema
*/
setInputSuggestions(fields: string[]) {
this.metadataFieldSuggestions.next(
fields.map((fieldName: string) => {
return {
displayValue: fieldName.split('.').join('.&#8203;'),
value: fieldName
};
})
);
}
/**
* Check if a user should be allowed to edit this field
* @return an observable that emits true when the user should be able to edit this field and false when they should not

View File

@@ -16,7 +16,7 @@
class="fas fa-undo-alt"></i>
<span class="d-none d-sm-inline">&nbsp;{{"item.edit.metadata.reinstate-button" | translate}}</span>
</button>
<button class="btn btn-primary" [disabled]="!(hasChanges() | async)"
<button class="btn btn-primary" [disabled]="!(hasChanges() | async) || !(isValid() | async)"
(click)="submit()"><i
class="fas fa-save"></i>
<span class="d-none d-sm-inline">&nbsp;{{"item.edit.metadata.save-button" | translate}}</span>
@@ -33,7 +33,6 @@
<tr *ngFor="let updateValue of ((updates$ | async)| dsObjectValues); trackBy: trackUpdate"
ds-edit-in-place-field
[fieldUpdate]="updateValue || {}"
[metadataFields]="metadataFields$ | async"
[url]="url"
[ngClass]="{
'table-warning': updateValue.changeType === 0,

View File

@@ -4,16 +4,13 @@ import { ItemDataService } from '../../../core/data/item-data.service';
import { ObjectUpdatesService } from '../../../core/data/object-updates/object-updates.service';
import { ActivatedRoute, Router } from '@angular/router';
import { cloneDeep } from 'lodash';
import { Observable } from 'rxjs';
import { first, map, switchMap, take, tap } from 'rxjs/operators';
import { first, switchMap, tap } from 'rxjs/operators';
import { getSucceededRemoteData } from '../../../core/shared/operators';
import { RemoteData } from '../../../core/data/remote-data';
import { NotificationsService } from '../../../shared/notifications/notifications.service';
import { TranslateService } from '@ngx-translate/core';
import { RegistryService } from '../../../core/registry/registry.service';
import { MetadataValue, MetadatumViewModel } from '../../../core/shared/metadata.models';
import { AbstractItemUpdateComponent } from '../abstract-item-update/abstract-item-update.component';
import { MetadataField } from '../../../core/metadata/metadata-field.model';
import { UpdateDataService } from '../../../core/data/update-data.service';
import { hasNoValue, hasValue, isNotEmpty } from '../../../shared/empty.util';
import { AlertType } from '../../../shared/alert/aletr-type';
@@ -43,11 +40,6 @@ export class ItemMetadataComponent extends AbstractItemUpdateComponent {
*/
@Input() updateService: UpdateDataService<Item>;
/**
* Observable with a list of strings with all existing metadata field keys
*/
metadataFields$: Observable<string[]>;
constructor(
public itemService: ItemDataService,
public objectUpdatesService: ObjectUpdatesService,
@@ -55,7 +47,6 @@ export class ItemMetadataComponent extends AbstractItemUpdateComponent {
public notificationsService: NotificationsService,
public translateService: TranslateService,
public route: ActivatedRoute,
public metadataFieldService: RegistryService,
) {
super(itemService, objectUpdatesService, router, notificationsService, translateService, route);
}
@@ -65,7 +56,6 @@ export class ItemMetadataComponent extends AbstractItemUpdateComponent {
*/
ngOnInit(): void {
super.ngOnInit();
this.metadataFields$ = this.findMetadataFields();
if (hasNoValue(this.updateService)) {
this.updateService = this.itemService;
}
@@ -139,16 +129,6 @@ export class ItemMetadataComponent extends AbstractItemUpdateComponent {
});
}
/**
* Method to request all metadata fields and convert them to a list of strings
*/
findMetadataFields(): Observable<string[]> {
return this.metadataFieldService.getAllMetadataFields().pipe(
getSucceededRemoteData(),
take(1),
map((remoteData$) => remoteData$.payload.page.map((field: MetadataField) => field.toString())));
}
/**
* Check for empty metadata UUIDs and fix them (empty UUIDs would break the object-update service)
*/

View File

@@ -0,0 +1,30 @@
import { Injectable } from '@angular/core';
import { DsoPageFeatureGuard } from '../../core/data/feature-authorization/feature-authorization-guard/dso-page-feature.guard';
import { Item } from '../../core/shared/item.model';
import { ItemPageResolver } from '../item-page.resolver';
import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service';
import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router';
import { Observable } from 'rxjs/internal/Observable';
import { FeatureID } from '../../core/data/feature-authorization/feature-id';
import { of as observableOf } from 'rxjs';
@Injectable({
providedIn: 'root'
})
/**
* Guard for preventing unauthorized access to certain {@link Item} pages requiring reinstate rights
*/
export class ItemPageReinstateGuard extends DsoPageFeatureGuard<Item> {
constructor(protected resolver: ItemPageResolver,
protected authorizationService: AuthorizationDataService,
protected router: Router) {
super(resolver, authorizationService, router);
}
/**
* Check reinstate authorization rights
*/
getFeatureID(next: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<FeatureID> {
return observableOf(FeatureID.ReinstateItem);
}
}

View File

@@ -0,0 +1,30 @@
import { DsoPageFeatureGuard } from '../../core/data/feature-authorization/feature-authorization-guard/dso-page-feature.guard';
import { Item } from '../../core/shared/item.model';
import { Injectable } from '@angular/core';
import { ItemPageResolver } from '../item-page.resolver';
import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service';
import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router';
import { Observable } from 'rxjs/internal/Observable';
import { FeatureID } from '../../core/data/feature-authorization/feature-id';
import { of as observableOf } from 'rxjs';
@Injectable({
providedIn: 'root'
})
/**
* Guard for preventing unauthorized access to certain {@link Item} pages requiring withdraw rights
*/
export class ItemPageWithdrawGuard extends DsoPageFeatureGuard<Item> {
constructor(protected resolver: ItemPageResolver,
protected authorizationService: AuthorizationDataService,
protected router: Router) {
super(resolver, authorizationService, router);
}
/**
* Check withdraw authorization rights
*/
getFeatureID(next: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<FeatureID> {
return observableOf(FeatureID.WithdrawItem);
}
}

View File

@@ -15,7 +15,7 @@
<a [routerLink]="getItemPage((itemRD$ | async)?.payload)">{{getItemPage((itemRD$ | async)?.payload)}}</a>
</div>
<div *ngFor="let operation of operations" class="w-100 pt-3">
<ds-item-operation [operation]="operation"></ds-item-operation>
<div *ngFor="let operation of (operations$ | async)" class="w-100" [ngClass]="{'pt-3': operation}">
<ds-item-operation *ngIf="operation" [operation]="operation"></ds-item-operation>
</div>
</div>

View File

@@ -12,6 +12,7 @@ import { By } from '@angular/platform-browser';
import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import { of as observableOf } from 'rxjs';
import { createSuccessfulRemoteDataObject } from '../../../shared/remote-data.utils';
import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service';
describe('ItemStatusComponent', () => {
let comp: ItemStatusComponent;
@@ -20,7 +21,10 @@ describe('ItemStatusComponent', () => {
const mockItem = Object.assign(new Item(), {
id: 'fake-id',
handle: 'fake/handle',
lastModified: '2018'
lastModified: '2018',
_links: {
self: { href: 'test-item-selflink' }
}
});
const itemPageUrl = `items/${mockItem.id}`;
@@ -31,13 +35,20 @@ describe('ItemStatusComponent', () => {
}
};
let authorizationService: AuthorizationDataService;
beforeEach(async(() => {
authorizationService = jasmine.createSpyObj('authorizationService', {
isAuthorized: observableOf(true)
});
TestBed.configureTestingModule({
imports: [CommonModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule],
declarations: [ItemStatusComponent],
providers: [
{ provide: ActivatedRoute, useValue: routeStub },
{ provide: HostWindowService, useValue: new HostWindowServiceStub(0) }
{ provide: HostWindowService, useValue: new HostWindowServiceStub(0) },
{ provide: AuthorizationDataService, useValue: authorizationService },
], schemas: [CUSTOM_ELEMENTS_SCHEMA]
}).compileComponents();
}));

View File

@@ -3,15 +3,19 @@ import { fadeIn, fadeInOut } from '../../../shared/animations/fade';
import { Item } from '../../../core/shared/item.model';
import { ActivatedRoute } from '@angular/router';
import { ItemOperation } from '../item-operation/itemOperation.model';
import { first, map } from 'rxjs/operators';
import { distinctUntilChanged, first, map } from 'rxjs/operators';
import { Observable } from 'rxjs';
import { RemoteData } from '../../../core/data/remote-data';
import { getItemEditRoute, getItemPageRoute } from '../../item-page-routing-paths';
import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service';
import { FeatureID } from '../../../core/data/feature-authorization/feature-id';
import { hasValue } from '../../../shared/empty.util';
import { BehaviorSubject } from 'rxjs/internal/BehaviorSubject';
@Component({
selector: 'ds-item-status',
templateUrl: './item-status.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
changeDetection: ChangeDetectionStrategy.Default,
animations: [
fadeIn,
fadeInOut
@@ -40,14 +44,15 @@ export class ItemStatusComponent implements OnInit {
* The possible actions that can be performed on the item
* key: id value: url to action's component
*/
operations: ItemOperation[];
operations$: BehaviorSubject<ItemOperation[]> = new BehaviorSubject<ItemOperation[]>([]);
/**
* The keys of the actions (to loop over)
*/
actionsKeys;
constructor(private route: ActivatedRoute) {
constructor(private route: ActivatedRoute,
private authorizationService: AuthorizationDataService) {
}
ngOnInit(): void {
@@ -67,21 +72,43 @@ export class ItemStatusComponent implements OnInit {
i18n example: 'item.edit.tabs.status.buttons.<key>.label'
The value is supposed to be a href for the button
*/
this.operations = [];
this.operations.push(new ItemOperation('authorizations', this.getCurrentUrl(item) + '/authorizations'));
this.operations.push(new ItemOperation('mappedCollections', this.getCurrentUrl(item) + '/mapper'));
if (item.isWithdrawn) {
this.operations.push(new ItemOperation('reinstate', this.getCurrentUrl(item) + '/reinstate'));
} else {
this.operations.push(new ItemOperation('withdraw', this.getCurrentUrl(item) + '/withdraw'));
}
const operations = [];
operations.push(new ItemOperation('authorizations', this.getCurrentUrl(item) + '/authorizations'));
operations.push(new ItemOperation('mappedCollections', this.getCurrentUrl(item) + '/mapper'));
operations.push(undefined);
// Store the index of the "withdraw" or "reinstate" operation, because it's added asynchronously
const indexOfWithdrawReinstate = operations.length - 1;
if (item.isDiscoverable) {
this.operations.push(new ItemOperation('private', this.getCurrentUrl(item) + '/private'));
operations.push(new ItemOperation('private', this.getCurrentUrl(item) + '/private'));
} else {
this.operations.push(new ItemOperation('public', this.getCurrentUrl(item) + '/public'));
operations.push(new ItemOperation('public', this.getCurrentUrl(item) + '/public'));
}
operations.push(new ItemOperation('delete', this.getCurrentUrl(item) + '/delete'));
operations.push(new ItemOperation('move', this.getCurrentUrl(item) + '/move'));
this.operations$.next(operations);
if (item.isWithdrawn) {
this.authorizationService.isAuthorized(FeatureID.ReinstateItem, item.self).pipe(distinctUntilChanged()).subscribe((authorized) => {
const newOperations = [...this.operations$.value];
if (authorized) {
newOperations[indexOfWithdrawReinstate] = new ItemOperation('reinstate', this.getCurrentUrl(item) + '/reinstate');
} else {
newOperations[indexOfWithdrawReinstate] = undefined;
}
this.operations$.next(newOperations);
});
} else {
this.authorizationService.isAuthorized(FeatureID.WithdrawItem, item.self).pipe(distinctUntilChanged()).subscribe((authorized) => {
const newOperations = [...this.operations$.value];
if (authorized) {
newOperations[indexOfWithdrawReinstate] = new ItemOperation('withdraw', this.getCurrentUrl(item) + '/withdraw');
} else {
newOperations[indexOfWithdrawReinstate] = undefined;
}
this.operations$.next(newOperations);
});
}
this.operations.push(new ItemOperation('delete', this.getCurrentUrl(item) + '/delete'));
this.operations.push(new ItemOperation('move', this.getCurrentUrl(item) + '/move'));
});
}
@@ -102,4 +129,8 @@ export class ItemStatusComponent implements OnInit {
return getItemEditRoute(item.id);
}
trackOperation(index: number, operation: ItemOperation) {
return hasValue(operation) ? operation.operationKey : undefined;
}
}

View File

@@ -0,0 +1,30 @@
import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router';
import { AuthorizationDataService } from '../core/data/feature-authorization/authorization-data.service';
import { ItemPageResolver } from './item-page.resolver';
import { Item } from '../core/shared/item.model';
import { DsoPageFeatureGuard } from '../core/data/feature-authorization/feature-authorization-guard/dso-page-feature.guard';
import { Observable } from 'rxjs/internal/Observable';
import { FeatureID } from '../core/data/feature-authorization/feature-id';
import { of as observableOf } from 'rxjs';
@Injectable({
providedIn: 'root'
})
/**
* Guard for preventing unauthorized access to certain {@link Item} pages requiring administrator rights
*/
export class ItemPageAdministratorGuard extends DsoPageFeatureGuard<Item> {
constructor(protected resolver: ItemPageResolver,
protected authorizationService: AuthorizationDataService,
protected router: Router) {
super(resolver, authorizationService, router);
}
/**
* Check administrator authorization rights
*/
getFeatureID(next: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<FeatureID> {
return observableOf(FeatureID.AdministratorOf);
}
}

View File

@@ -10,6 +10,7 @@ import { DSOBreadcrumbsService } from '../core/breadcrumbs/dso-breadcrumbs.servi
import { LinkService } from '../core/cache/builders/link.service';
import { UploadBitstreamComponent } from './bitstreams/upload/upload-bitstream.component';
import { UPLOAD_BITSTREAM_PATH, ITEM_EDIT_PATH } from './item-page-routing-paths';
import { ItemPageAdministratorGuard } from './item-page-administrator.guard';
@NgModule({
imports: [
@@ -34,7 +35,7 @@ import { UPLOAD_BITSTREAM_PATH, ITEM_EDIT_PATH } from './item-page-routing-paths
{
path: ITEM_EDIT_PATH,
loadChildren: './edit-item-page/edit-item-page.module#EditItemPageModule',
canActivate: [AuthenticatedGuard]
canActivate: [ItemPageAdministratorGuard]
},
{
path: UPLOAD_BITSTREAM_PATH,
@@ -49,7 +50,8 @@ import { UPLOAD_BITSTREAM_PATH, ITEM_EDIT_PATH } from './item-page-routing-paths
ItemPageResolver,
ItemBreadcrumbResolver,
DSOBreadcrumbsService,
LinkService
LinkService,
ItemPageAdministratorGuard
]
})

View File

@@ -60,3 +60,8 @@ export const UNAUTHORIZED_PATH = 'unauthorized';
export function getUnauthorizedRoute() {
return `/${UNAUTHORIZED_PATH}`;
}
export const INFO_MODULE_PATH = 'info';
export function getInfoModulePath() {
return `/${INFO_MODULE_PATH}`;
}

View File

@@ -1,5 +1,6 @@
import { NgModule } from '@angular/core';
import { RouterModule } from '@angular/router';
import { AuthBlockingGuard } from './core/auth/auth-blocking.guard';
import { PageNotFoundComponent } from './pagenotfound/pagenotfound.component';
import { AuthenticatedGuard } from './core/auth/authenticated.guard';
@@ -12,55 +13,65 @@ import {
REGISTER_PATH,
PROFILE_MODULE_PATH,
ADMIN_MODULE_PATH,
BITSTREAM_MODULE_PATH
BITSTREAM_MODULE_PATH,
INFO_MODULE_PATH
} from './app-routing-paths';
import { COLLECTION_MODULE_PATH } from './+collection-page/collection-page-routing-paths';
import { COMMUNITY_MODULE_PATH } from './+community-page/community-page-routing-paths';
import { ITEM_MODULE_PATH } from './+item-page/item-page-routing-paths';
import { ReloadGuard } from './core/reload/reload.guard';
import { EndUserAgreementCurrentUserGuard } from './core/end-user-agreement/end-user-agreement-current-user.guard';
import { SiteRegisterGuard } from './core/data/feature-authorization/feature-authorization-guard/site-register.guard';
@NgModule({
imports: [
RouterModule.forRoot([
{ path: '', redirectTo: '/home', pathMatch: 'full' },
{ path: 'reload/:rnd', redirectTo: '/home', pathMatch: 'full' },
{ path: 'home', loadChildren: './+home-page/home-page.module#HomePageModule', data: { showBreadcrumbs: false } },
{ path: 'community-list', loadChildren: './community-list-page/community-list-page.module#CommunityListPageModule' },
{ path: 'id', loadChildren: './+lookup-by-id/lookup-by-id.module#LookupIdModule' },
{ path: 'handle', loadChildren: './+lookup-by-id/lookup-by-id.module#LookupIdModule' },
{ path: REGISTER_PATH, loadChildren: './register-page/register-page.module#RegisterPageModule' },
{ path: FORGOT_PASSWORD_PATH, loadChildren: './forgot-password/forgot-password.module#ForgotPasswordModule' },
{ path: COMMUNITY_MODULE_PATH, loadChildren: './+community-page/community-page.module#CommunityPageModule' },
{ path: COLLECTION_MODULE_PATH, loadChildren: './+collection-page/collection-page.module#CollectionPageModule' },
{ path: ITEM_MODULE_PATH, loadChildren: './+item-page/item-page.module#ItemPageModule' },
{ path: BITSTREAM_MODULE_PATH, loadChildren: './+bitstream-page/bitstream-page.module#BitstreamPageModule' },
{
path: 'mydspace',
loadChildren: './+my-dspace-page/my-dspace-page.module#MyDSpacePageModule',
canActivate: [AuthenticatedGuard]
},
{ path: 'search', loadChildren: './+search-page/search-page-routing.module#SearchPageRoutingModule' },
{ path: 'browse', loadChildren: './+browse-by/browse-by.module#BrowseByModule'},
{ path: ADMIN_MODULE_PATH, loadChildren: './+admin/admin.module#AdminModule', canActivate: [SiteAdministratorGuard] },
{ path: 'login', loadChildren: './+login-page/login-page.module#LoginPageModule' },
{ path: 'logout', loadChildren: './+logout-page/logout-page.module#LogoutPageModule' },
{ path: 'submit', loadChildren: './+submit-page/submit-page.module#SubmitPageModule' },
{ path: 'import-external', loadChildren: './+import-external-page/import-external-page.module#ImportExternalPageModule' },
{
path: 'workspaceitems',
loadChildren: './+workspaceitems-edit-page/workspaceitems-edit-page.module#WorkspaceitemsEditPageModule'
},
{
path: WORKFLOW_ITEM_MODULE_PATH,
loadChildren: './+workflowitems-edit-page/workflowitems-edit-page.module#WorkflowItemsEditPageModule'
},
{
path: PROFILE_MODULE_PATH,
loadChildren: './profile-page/profile-page.module#ProfilePageModule', canActivate: [AuthenticatedGuard]
},
{ path: 'processes', loadChildren: './process-page/process-page.module#ProcessPageModule', canActivate: [AuthenticatedGuard] },
{ path: UNAUTHORIZED_PATH, component: UnauthorizedComponent },
{ path: '**', pathMatch: 'full', component: PageNotFoundComponent },
],
{ path: '', canActivate: [AuthBlockingGuard],
children: [
{ path: '', redirectTo: '/home', pathMatch: 'full' },
{ path: 'reload/:rnd', component: PageNotFoundComponent, pathMatch: 'full', canActivate: [ReloadGuard] },
{ path: 'home', loadChildren: './+home-page/home-page.module#HomePageModule', data: { showBreadcrumbs: false }, canActivate: [EndUserAgreementCurrentUserGuard] },
{ path: 'community-list', loadChildren: './community-list-page/community-list-page.module#CommunityListPageModule', canActivate: [EndUserAgreementCurrentUserGuard] },
{ path: 'id', loadChildren: './+lookup-by-id/lookup-by-id.module#LookupIdModule', canActivate: [EndUserAgreementCurrentUserGuard] },
{ path: 'handle', loadChildren: './+lookup-by-id/lookup-by-id.module#LookupIdModule', canActivate: [EndUserAgreementCurrentUserGuard] },
{ path: REGISTER_PATH, loadChildren: './register-page/register-page.module#RegisterPageModule', canActivate: [SiteRegisterGuard] },
{ path: FORGOT_PASSWORD_PATH, loadChildren: './forgot-password/forgot-password.module#ForgotPasswordModule', canActivate: [EndUserAgreementCurrentUserGuard] },
{ path: COMMUNITY_MODULE_PATH, loadChildren: './+community-page/community-page.module#CommunityPageModule', canActivate: [EndUserAgreementCurrentUserGuard] },
{ path: COLLECTION_MODULE_PATH, loadChildren: './+collection-page/collection-page.module#CollectionPageModule', canActivate: [EndUserAgreementCurrentUserGuard] },
{ path: ITEM_MODULE_PATH, loadChildren: './+item-page/item-page.module#ItemPageModule', canActivate: [EndUserAgreementCurrentUserGuard] },
{ path: BITSTREAM_MODULE_PATH, loadChildren: './+bitstream-page/bitstream-page.module#BitstreamPageModule', canActivate: [EndUserAgreementCurrentUserGuard] },
{
path: 'mydspace',
loadChildren: './+my-dspace-page/my-dspace-page.module#MyDSpacePageModule',
canActivate: [AuthenticatedGuard, EndUserAgreementCurrentUserGuard]
},
{ path: 'search', loadChildren: './+search-page/search-page-routing.module#SearchPageRoutingModule', canActivate: [EndUserAgreementCurrentUserGuard] },
{ path: 'browse', loadChildren: './+browse-by/browse-by.module#BrowseByModule', canActivate: [EndUserAgreementCurrentUserGuard] },
{ path: ADMIN_MODULE_PATH, loadChildren: './+admin/admin.module#AdminModule', canActivate: [SiteAdministratorGuard, EndUserAgreementCurrentUserGuard] },
{ path: 'login', loadChildren: './+login-page/login-page.module#LoginPageModule' },
{ path: 'logout', loadChildren: './+logout-page/logout-page.module#LogoutPageModule' },
{ path: 'submit', loadChildren: './+submit-page/submit-page.module#SubmitPageModule', canActivate: [EndUserAgreementCurrentUserGuard] },
{ path: 'import-external', loadChildren: './+import-external-page/import-external-page.module#ImportExternalPageModule', canActivate: [EndUserAgreementCurrentUserGuard] },
{
path: 'workspaceitems',
loadChildren: './+workspaceitems-edit-page/workspaceitems-edit-page.module#WorkspaceitemsEditPageModule',
canActivate: [EndUserAgreementCurrentUserGuard]
},
{
path: WORKFLOW_ITEM_MODULE_PATH,
loadChildren: './+workflowitems-edit-page/workflowitems-edit-page.module#WorkflowItemsEditPageModule',
canActivate: [EndUserAgreementCurrentUserGuard]
},
{
path: PROFILE_MODULE_PATH,
loadChildren: './profile-page/profile-page.module#ProfilePageModule', canActivate: [AuthenticatedGuard, EndUserAgreementCurrentUserGuard]
},
{ path: 'processes', loadChildren: './process-page/process-page.module#ProcessPageModule', canActivate: [AuthenticatedGuard, EndUserAgreementCurrentUserGuard] },
{ path: INFO_MODULE_PATH, loadChildren: './info/info.module#InfoModule' },
{ path: UNAUTHORIZED_PATH, component: UnauthorizedComponent },
{ path: '**', pathMatch: 'full', component: PageNotFoundComponent },
]}
],
{
onSameUrlNavigation: 'reload',
})

View File

@@ -1,4 +1,4 @@
<div class="outer-wrapper">
<div class="outer-wrapper" *ngIf="isNotAuthBlocking$ | async; else authLoader">
<ds-admin-sidebar></ds-admin-sidebar>
<div class="inner-wrapper" [@slideSidebarPadding]="{
value: (!(sidebarVisible | async) ? 'hidden' : (slideSidebarOver | async) ? 'shown' : 'expanded'),
@@ -23,3 +23,8 @@
<ds-footer></ds-footer>
</div>
</div>
<ng-template #authLoader>
<div class="text-center ds-full-screen-loader d-flex align-items-center flex-column justify-content-center">
<ds-loading [showMessage]="false"></ds-loading>
</div>
</ng-template>

View File

@@ -47,3 +47,7 @@ ds-admin-sidebar {
position: fixed;
z-index: $sidebar-z-index;
}
.ds-full-screen-loader {
height: 100vh;
}

View File

@@ -1,9 +1,8 @@
import * as ngrx from '@ngrx/store';
import { async, ComponentFixture, inject, TestBed } from '@angular/core/testing';
import { CUSTOM_ELEMENTS_SCHEMA, DebugElement } from '@angular/core';
import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import { CommonModule } from '@angular/common';
import { By } from '@angular/platform-browser';
import { ActivatedRoute, Router } from '@angular/router';
import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
import { Store, StoreModule } from '@ngrx/store';
import { Angulartics2GoogleAnalytics } from 'angulartics2/ga';
@@ -32,11 +31,11 @@ import { RouterMock } from './shared/mocks/router.mock';
import { Angulartics2DSpace } from './statistics/angulartics/dspace-provider';
import { storeModuleConfig } from './app.reducer';
import { LocaleService } from './core/locale/locale.service';
import { authReducer } from './core/auth/auth.reducer';
import { cold } from 'jasmine-marbles';
let comp: AppComponent;
let fixture: ComponentFixture<AppComponent>;
let de: DebugElement;
let el: HTMLElement;
const menuService = new MenuServiceStub();
describe('App component', () => {
@@ -52,7 +51,7 @@ describe('App component', () => {
return TestBed.configureTestingModule({
imports: [
CommonModule,
StoreModule.forRoot({}, storeModuleConfig),
StoreModule.forRoot(authReducer, storeModuleConfig),
TranslateModule.forRoot({
loader: {
provide: TranslateLoader,
@@ -82,12 +81,19 @@ describe('App component', () => {
// synchronous beforeEach
beforeEach(() => {
fixture = TestBed.createComponent(AppComponent);
spyOnProperty(ngrx, 'select').and.callFake(() => {
return () => {
return () => cold('a', {
a: {
core: { auth: { loading: false } }
}
})
};
});
fixture = TestBed.createComponent(AppComponent);
comp = fixture.componentInstance; // component test instance
// query for the <div class='outer-wrapper'> by CSS element selector
de = fixture.debugElement.query(By.css('div.outer-wrapper'));
el = de.nativeElement;
fixture.detectChanges();
});
it('should create component', inject([AppComponent], (app: AppComponent) => {

View File

@@ -1,11 +1,11 @@
import { delay, filter, map, take } from 'rxjs/operators';
import { delay, map, distinctUntilChanged, filter, take } from 'rxjs/operators';
import {
AfterViewInit,
ChangeDetectionStrategy,
Component,
HostListener,
Inject,
OnInit,
OnInit, Optional,
ViewEncapsulation
} from '@angular/core';
import { NavigationCancel, NavigationEnd, NavigationStart, Router } from '@angular/router';
@@ -19,7 +19,7 @@ import { MetadataService } from './core/metadata/metadata.service';
import { HostWindowResizeAction } from './shared/host-window.actions';
import { HostWindowState } from './shared/search/host-window.reducer';
import { NativeWindowRef, NativeWindowService } from './core/services/window.service';
import { isAuthenticated } from './core/auth/selectors';
import { isAuthenticationBlocking } from './core/auth/selectors';
import { AuthService } from './core/auth/auth.service';
import { CSSVariableService } from './shared/sass-helper/sass-helper.service';
import { MenuService } from './shared/menu/menu.service';
@@ -31,8 +31,8 @@ import { Angulartics2DSpace } from './statistics/angulartics/dspace-provider';
import { environment } from '../environments/environment';
import { models } from './core/core.module';
import { LocaleService } from './core/locale/locale.service';
export const LANG_COOKIE = 'language_cookie';
import { hasValue } from './shared/empty.util';
import { KlaroService } from './shared/cookies/klaro.service';
@Component({
selector: 'ds-app',
@@ -52,6 +52,11 @@ export class AppComponent implements OnInit, AfterViewInit {
notificationOptions = environment.notifications;
models;
/**
* Whether or not the authentication is currently blocking the UI
*/
isNotAuthBlocking$: Observable<boolean>;
constructor(
@Inject(NativeWindowService) private _window: NativeWindowRef,
private translate: TranslateService,
@@ -64,8 +69,10 @@ export class AppComponent implements OnInit, AfterViewInit {
private cssService: CSSVariableService,
private menuService: MenuService,
private windowService: HostWindowService,
private localeService: LocaleService
private localeService: LocaleService,
@Optional() private cookiesService: KlaroService
) {
/* Use models object so all decorators are actually called */
this.models = models;
// Load all the languages that are defined as active from the config file
@@ -86,19 +93,25 @@ export class AppComponent implements OnInit, AfterViewInit {
console.info(environment);
}
this.storeCSSVariables();
}
ngOnInit() {
this.isNotAuthBlocking$ = this.store.pipe(select(isAuthenticationBlocking)).pipe(
map((isBlocking: boolean) => isBlocking === false),
distinctUntilChanged()
);
this.isNotAuthBlocking$
.pipe(
filter((notBlocking: boolean) => notBlocking),
take(1)
).subscribe(() => this.initializeKlaro());
const env: string = environment.production ? 'Production' : 'Development';
const color: string = environment.production ? 'red' : 'green';
console.info(`Environment: %c${env}`, `color: ${color}; font-weight: bold;`);
this.dispatchWindowSize(this._window.nativeWindow.innerWidth, this._window.nativeWindow.innerHeight);
// Whether is not authenticathed try to retrieve a possible stored auth token
this.store.pipe(select(isAuthenticated),
take(1),
filter((authenticated) => !authenticated)
).subscribe((authenticated) => this.authService.checkAuthenticationToken());
this.sidebarVisible = this.menuService.isMenuVisible(MenuID.ADMIN);
this.collapsedSidebarWidth = this.cssService.getVariable('collapsedSidebarWidth');
@@ -154,4 +167,9 @@ export class AppComponent implements OnInit, AfterViewInit {
);
}
private initializeKlaro() {
if (hasValue(this.cookiesService)) {
this.cookiesService.initialize()
}
}
}

View File

@@ -1,11 +1,11 @@
import { APP_BASE_HREF, CommonModule } from '@angular/common';
import { HttpClientModule } from '@angular/common/http';
import { NgModule } from '@angular/core';
import { APP_INITIALIZER, NgModule } from '@angular/core';
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
import { EffectsModule } from '@ngrx/effects';
import { RouterStateSerializer, StoreRouterConnectingModule } from '@ngrx/router-store';
import { MetaReducer, StoreModule, USER_PROVIDED_META_REDUCERS } from '@ngrx/store';
import { MetaReducer, Store, StoreModule, USER_PROVIDED_META_REDUCERS } from '@ngrx/store';
import { StoreDevtoolsModule } from '@ngrx/store-devtools';
import { DYNAMIC_MATCHER_PROVIDERS } from '@ng-dynamic-forms/core';
import { TranslateModule } from '@ngx-translate/core';
@@ -21,6 +21,7 @@ import { AppComponent } from './app.component';
import { appEffects } from './app.effects';
import { appMetaReducers, debugMetaReducers } from './app.metareducers';
import { appReducers, AppState, storeModuleConfig } from './app.reducer';
import { CheckAuthenticationTokenAction } from './core/auth/auth.actions';
import { CoreModule } from './core/core.module';
import { ClientCookieService } from './core/services/client-cookie.service';
@@ -91,6 +92,15 @@ const PROVIDERS = [
useClass: DSpaceRouterStateSerializer
},
ClientCookieService,
// Check the authentication token when the app initializes
{
provide: APP_INITIALIZER,
useFactory: (store: Store<AppState>,) => {
return () => store.dispatch(new CheckAuthenticationTokenAction());
},
deps: [ Store ],
multi: true
},
...DYNAMIC_MATCHER_PROVIDERS,
];

View File

@@ -4,7 +4,6 @@ import { SharedModule } from '../shared/shared.module';
import { CommunityListPageComponent } from './community-list-page.component';
import { CommunityListPageRoutingModule } from './community-list-page.routing.module';
import { CommunityListComponent } from './community-list/community-list.component';
import { CdkTreeModule } from '@angular/cdk/tree';
/**
* The page which houses a title and the community list, as described in community-list.component
@@ -13,8 +12,7 @@ import { CdkTreeModule } from '@angular/cdk/tree';
imports: [
CommonModule,
SharedModule,
CommunityListPageRoutingModule,
CdkTreeModule,
CommunityListPageRoutingModule
],
declarations: [
CommunityListPageComponent,

View File

@@ -0,0 +1,62 @@
import { Store } from '@ngrx/store';
import * as ngrx from '@ngrx/store';
import { cold, getTestScheduler, initTestScheduler, resetTestScheduler } from 'jasmine-marbles/es6';
import { of as observableOf } from 'rxjs';
import { AppState } from '../../app.reducer';
import { AuthBlockingGuard } from './auth-blocking.guard';
describe('AuthBlockingGuard', () => {
let guard: AuthBlockingGuard;
beforeEach(() => {
guard = new AuthBlockingGuard(new Store<AppState>(undefined, undefined, undefined));
initTestScheduler();
});
afterEach(() => {
getTestScheduler().flush();
resetTestScheduler();
});
describe(`canActivate`, () => {
describe(`when authState.loading is undefined`, () => {
beforeEach(() => {
spyOnProperty(ngrx, 'select').and.callFake(() => {
return () => {
return () => observableOf(undefined);
};
})
});
it(`should not emit anything`, () => {
expect(guard.canActivate()).toBeObservable(cold('|'));
});
});
describe(`when authState.loading is true`, () => {
beforeEach(() => {
spyOnProperty(ngrx, 'select').and.callFake(() => {
return () => {
return () => observableOf(true);
};
})
});
it(`should not emit anything`, () => {
expect(guard.canActivate()).toBeObservable(cold('|'));
});
});
describe(`when authState.loading is false`, () => {
beforeEach(() => {
spyOnProperty(ngrx, 'select').and.callFake(() => {
return () => {
return () => observableOf(false);
};
})
});
it(`should succeed`, () => {
expect(guard.canActivate()).toBeObservable(cold('(a|)', { a: true }));
});
});
});
});

View File

@@ -0,0 +1,34 @@
import { Injectable } from '@angular/core';
import { CanActivate } from '@angular/router';
import { select, Store } from '@ngrx/store';
import { Observable } from 'rxjs';
import { distinctUntilChanged, filter, map, take } from 'rxjs/operators';
import { AppState } from '../../app.reducer';
import { isAuthenticationBlocking } from './selectors';
/**
* A guard that blocks the loading of any
* route until the authentication status has loaded.
* To ensure all rest requests get the correct auth header.
*/
@Injectable({
providedIn: 'root'
})
export class AuthBlockingGuard implements CanActivate {
constructor(private store: Store<AppState>) {
}
/**
* True when the authentication isn't blocking everything
*/
canActivate(): Observable<boolean> {
return this.store.pipe(select(isAuthenticationBlocking)).pipe(
map((isBlocking: boolean) => isBlocking === false),
distinctUntilChanged(),
filter((finished: boolean) => finished === true),
take(1),
);
}
}

View File

@@ -34,6 +34,7 @@ export const AuthActionTypes = {
RETRIEVE_AUTHENTICATED_EPERSON: type('dspace/auth/RETRIEVE_AUTHENTICATED_EPERSON'),
RETRIEVE_AUTHENTICATED_EPERSON_SUCCESS: type('dspace/auth/RETRIEVE_AUTHENTICATED_EPERSON_SUCCESS'),
RETRIEVE_AUTHENTICATED_EPERSON_ERROR: type('dspace/auth/RETRIEVE_AUTHENTICATED_EPERSON_ERROR'),
REDIRECT_AFTER_LOGIN_SUCCESS: type('dspace/auth/REDIRECT_AFTER_LOGIN_SUCCESS')
};
/* tslint:disable:max-classes-per-file */
@@ -335,6 +336,20 @@ export class SetRedirectUrlAction implements Action {
}
}
/**
* Start loading for a hard redirect
* @class StartHardRedirectLoadingAction
* @implements {Action}
*/
export class RedirectAfterLoginSuccessAction implements Action {
public type: string = AuthActionTypes.REDIRECT_AFTER_LOGIN_SUCCESS;
payload: string;
constructor(url: string) {
this.payload = url;
}
}
/**
* Retrieve the authenticated eperson.
* @class RetrieveAuthenticatedEpersonAction
@@ -402,8 +417,8 @@ export type AuthActions
| RetrieveAuthMethodsSuccessAction
| RetrieveAuthMethodsErrorAction
| RetrieveTokenAction
| ResetAuthenticationMessagesAction
| RetrieveAuthenticatedEpersonAction
| RetrieveAuthenticatedEpersonErrorAction
| RetrieveAuthenticatedEpersonSuccessAction
| SetRedirectUrlAction;
| SetRedirectUrlAction
| RedirectAfterLoginSuccessAction;

View File

@@ -27,6 +27,7 @@ import {
CheckAuthenticationTokenCookieAction,
LogOutErrorAction,
LogOutSuccessAction,
RedirectAfterLoginSuccessAction,
RefreshTokenAction,
RefreshTokenErrorAction,
RefreshTokenSuccessAction,
@@ -79,7 +80,26 @@ export class AuthEffects {
public authenticatedSuccess$: Observable<Action> = this.actions$.pipe(
ofType(AuthActionTypes.AUTHENTICATED_SUCCESS),
tap((action: AuthenticatedSuccessAction) => this.authService.storeToken(action.payload.authToken)),
map((action: AuthenticatedSuccessAction) => new RetrieveAuthenticatedEpersonAction(action.payload.userHref))
switchMap((action: AuthenticatedSuccessAction) => this.authService.getRedirectUrl().pipe(
take(1),
map((redirectUrl: string) => [action, redirectUrl])
)),
map(([action, redirectUrl]: [AuthenticatedSuccessAction, string]) => {
if (hasValue(redirectUrl)) {
return new RedirectAfterLoginSuccessAction(redirectUrl);
} else {
return new RetrieveAuthenticatedEpersonAction(action.payload.userHref);
}
})
);
@Effect({ dispatch: false })
public redirectAfterLoginSuccess$: Observable<Action> = this.actions$.pipe(
ofType(AuthActionTypes.REDIRECT_AFTER_LOGIN_SUCCESS),
tap((action: RedirectAfterLoginSuccessAction) => {
this.authService.clearRedirectUrl();
this.authService.navigateToRedirectUrl(action.payload);
})
);
// It means "reacts to this action but don't send another"
@@ -201,13 +221,6 @@ export class AuthEffects {
tap(() => this.authService.refreshAfterLogout())
);
@Effect({ dispatch: false })
public redirectToLogin$: Observable<Action> = this.actions$
.pipe(ofType(AuthActionTypes.REDIRECT_AUTHENTICATION_REQUIRED),
tap(() => this.authService.removeToken()),
tap(() => this.authService.redirectToLogin())
);
@Effect({ dispatch: false })
public redirectToLoginTokenExpired$: Observable<Action> = this.actions$
.pipe(

View File

@@ -251,7 +251,6 @@ export class AuthInterceptor implements HttpInterceptor {
// Pass on the new request instead of the original request.
return next.handle(newReq).pipe(
// tap((response) => console.log('next.handle: ', response)),
map((response) => {
// Intercept a Login/Logout response
if (response instanceof HttpResponse && this.isSuccess(response) && this.isAuthRequest(response)) {

View File

@@ -42,6 +42,7 @@ describe('authReducer', () => {
initialState = {
authenticated: false,
loaded: false,
blocking: true,
loading: false,
};
const action = new AuthenticateAction('user', 'password');
@@ -49,6 +50,7 @@ describe('authReducer', () => {
state = {
authenticated: false,
loaded: false,
blocking: true,
error: undefined,
loading: true,
info: undefined
@@ -62,6 +64,7 @@ describe('authReducer', () => {
authenticated: false,
loaded: false,
error: undefined,
blocking: true,
loading: true,
info: undefined
};
@@ -76,6 +79,7 @@ describe('authReducer', () => {
authenticated: false,
loaded: false,
error: undefined,
blocking: true,
loading: true,
info: undefined
};
@@ -84,6 +88,7 @@ describe('authReducer', () => {
state = {
authenticated: false,
loaded: false,
blocking: false,
loading: false,
info: undefined,
authToken: undefined,
@@ -96,6 +101,7 @@ describe('authReducer', () => {
it('should properly set the state, in response to a AUTHENTICATED action', () => {
initialState = {
authenticated: false,
blocking: false,
loaded: false,
error: undefined,
loading: true,
@@ -103,8 +109,15 @@ describe('authReducer', () => {
};
const action = new AuthenticatedAction(mockTokenInfo);
const newState = authReducer(initialState, action);
expect(newState).toEqual(initialState);
state = {
authenticated: false,
blocking: true,
loaded: false,
error: undefined,
loading: true,
info: undefined
};
expect(newState).toEqual(state);
});
it('should properly set the state, in response to a AUTHENTICATED_SUCCESS action', () => {
@@ -112,6 +125,7 @@ describe('authReducer', () => {
authenticated: false,
loaded: false,
error: undefined,
blocking: true,
loading: true,
info: undefined
};
@@ -122,6 +136,7 @@ describe('authReducer', () => {
authToken: mockTokenInfo,
loaded: false,
error: undefined,
blocking: true,
loading: true,
info: undefined
};
@@ -133,6 +148,7 @@ describe('authReducer', () => {
authenticated: false,
loaded: false,
error: undefined,
blocking: true,
loading: true,
info: undefined
};
@@ -143,6 +159,7 @@ describe('authReducer', () => {
authToken: undefined,
error: 'Test error message',
loaded: true,
blocking: false,
loading: false,
info: undefined
};
@@ -153,6 +170,7 @@ describe('authReducer', () => {
initialState = {
authenticated: false,
loaded: false,
blocking: false,
loading: false,
};
const action = new CheckAuthenticationTokenAction();
@@ -160,6 +178,7 @@ describe('authReducer', () => {
state = {
authenticated: false,
loaded: false,
blocking: true,
loading: true,
};
expect(newState).toEqual(state);
@@ -169,6 +188,7 @@ describe('authReducer', () => {
initialState = {
authenticated: false,
loaded: false,
blocking: false,
loading: true,
};
const action = new CheckAuthenticationTokenCookieAction();
@@ -176,6 +196,7 @@ describe('authReducer', () => {
state = {
authenticated: false,
loaded: false,
blocking: true,
loading: true,
};
expect(newState).toEqual(state);
@@ -187,6 +208,7 @@ describe('authReducer', () => {
authToken: mockTokenInfo,
loaded: true,
error: undefined,
blocking: false,
loading: false,
info: undefined,
userId: EPersonMock.id
@@ -204,6 +226,7 @@ describe('authReducer', () => {
authToken: mockTokenInfo,
loaded: true,
error: undefined,
blocking: false,
loading: false,
info: undefined,
userId: EPersonMock.id
@@ -216,7 +239,8 @@ describe('authReducer', () => {
authToken: undefined,
error: undefined,
loaded: false,
loading: false,
blocking: true,
loading: true,
info: undefined,
refreshing: false,
userId: undefined
@@ -230,6 +254,7 @@ describe('authReducer', () => {
authToken: mockTokenInfo,
loaded: true,
error: undefined,
blocking: false,
loading: false,
info: undefined,
userId: EPersonMock.id
@@ -242,6 +267,7 @@ describe('authReducer', () => {
authToken: mockTokenInfo,
loaded: true,
error: 'Test error message',
blocking: false,
loading: false,
info: undefined,
userId: EPersonMock.id
@@ -255,6 +281,7 @@ describe('authReducer', () => {
authToken: mockTokenInfo,
loaded: false,
error: undefined,
blocking: true,
loading: true,
info: undefined
};
@@ -265,6 +292,7 @@ describe('authReducer', () => {
authToken: mockTokenInfo,
loaded: true,
error: undefined,
blocking: false,
loading: false,
info: undefined,
userId: EPersonMock.id
@@ -277,6 +305,7 @@ describe('authReducer', () => {
authenticated: false,
loaded: false,
error: undefined,
blocking: true,
loading: true,
info: undefined
};
@@ -287,6 +316,7 @@ describe('authReducer', () => {
authToken: undefined,
error: 'Test error message',
loaded: true,
blocking: false,
loading: false,
info: undefined
};
@@ -299,6 +329,7 @@ describe('authReducer', () => {
authToken: mockTokenInfo,
loaded: true,
error: undefined,
blocking: false,
loading: false,
info: undefined,
userId: EPersonMock.id
@@ -311,6 +342,7 @@ describe('authReducer', () => {
authToken: mockTokenInfo,
loaded: true,
error: undefined,
blocking: false,
loading: false,
info: undefined,
userId: EPersonMock.id,
@@ -325,6 +357,7 @@ describe('authReducer', () => {
authToken: mockTokenInfo,
loaded: true,
error: undefined,
blocking: false,
loading: false,
info: undefined,
userId: EPersonMock.id,
@@ -338,6 +371,7 @@ describe('authReducer', () => {
authToken: newTokenInfo,
loaded: true,
error: undefined,
blocking: false,
loading: false,
info: undefined,
userId: EPersonMock.id,
@@ -352,6 +386,7 @@ describe('authReducer', () => {
authToken: mockTokenInfo,
loaded: true,
error: undefined,
blocking: false,
loading: false,
info: undefined,
userId: EPersonMock.id,
@@ -364,6 +399,7 @@ describe('authReducer', () => {
authToken: undefined,
error: undefined,
loaded: false,
blocking: false,
loading: false,
info: undefined,
refreshing: false,
@@ -378,6 +414,7 @@ describe('authReducer', () => {
authToken: mockTokenInfo,
loaded: true,
error: undefined,
blocking: false,
loading: false,
info: undefined,
userId: EPersonMock.id
@@ -387,6 +424,7 @@ describe('authReducer', () => {
authenticated: false,
authToken: undefined,
loaded: false,
blocking: false,
loading: false,
error: undefined,
info: 'Message',
@@ -410,6 +448,7 @@ describe('authReducer', () => {
initialState = {
authenticated: false,
loaded: false,
blocking: false,
loading: false,
};
const action = new AddAuthenticationMessageAction('Message');
@@ -417,6 +456,7 @@ describe('authReducer', () => {
state = {
authenticated: false,
loaded: false,
blocking: false,
loading: false,
info: 'Message'
};
@@ -427,6 +467,7 @@ describe('authReducer', () => {
initialState = {
authenticated: false,
loaded: false,
blocking: false,
loading: false,
error: 'Error',
info: 'Message'
@@ -436,6 +477,7 @@ describe('authReducer', () => {
state = {
authenticated: false,
loaded: false,
blocking: false,
loading: false,
error: undefined,
info: undefined
@@ -447,6 +489,7 @@ describe('authReducer', () => {
initialState = {
authenticated: false,
loaded: false,
blocking: false,
loading: false
};
const action = new SetRedirectUrlAction('redirect.url');
@@ -454,6 +497,7 @@ describe('authReducer', () => {
state = {
authenticated: false,
loaded: false,
blocking: false,
loading: false,
redirectUrl: 'redirect.url'
};
@@ -464,6 +508,7 @@ describe('authReducer', () => {
initialState = {
authenticated: false,
loaded: false,
blocking: false,
loading: false,
authMethods: []
};
@@ -472,6 +517,7 @@ describe('authReducer', () => {
state = {
authenticated: false,
loaded: false,
blocking: true,
loading: true,
authMethods: []
};
@@ -482,6 +528,7 @@ describe('authReducer', () => {
initialState = {
authenticated: false,
loaded: false,
blocking: true,
loading: true,
authMethods: []
};
@@ -494,6 +541,7 @@ describe('authReducer', () => {
state = {
authenticated: false,
loaded: false,
blocking: false,
loading: false,
authMethods: authMethods
};
@@ -504,6 +552,7 @@ describe('authReducer', () => {
initialState = {
authenticated: false,
loaded: false,
blocking: true,
loading: true,
authMethods: []
};
@@ -513,6 +562,7 @@ describe('authReducer', () => {
state = {
authenticated: false,
loaded: false,
blocking: false,
loading: false,
authMethods: [new AuthMethod(AuthMethodType.Password)]
};

View File

@@ -39,6 +39,10 @@ export interface AuthState {
// true when loading
loading: boolean;
// true when everything else should wait for authorization
// to complete
blocking: boolean;
// info message
info?: string;
@@ -62,6 +66,7 @@ export interface AuthState {
const initialState: AuthState = {
authenticated: false,
loaded: false,
blocking: true,
loading: false,
authMethods: []
};
@@ -86,7 +91,8 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut
case AuthActionTypes.CHECK_AUTHENTICATION_TOKEN:
case AuthActionTypes.CHECK_AUTHENTICATION_TOKEN_COOKIE:
return Object.assign({}, state, {
loading: true
loading: true,
blocking: true
});
case AuthActionTypes.AUTHENTICATED_ERROR:
@@ -96,6 +102,7 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut
authToken: undefined,
error: (action as AuthenticationErrorAction).payload.message,
loaded: true,
blocking: false,
loading: false
});
@@ -110,6 +117,7 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut
loaded: true,
error: undefined,
loading: false,
blocking: false,
info: undefined,
userId: (action as RetrieveAuthenticatedEpersonSuccessAction).payload
});
@@ -119,6 +127,7 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut
authenticated: false,
authToken: undefined,
error: (action as AuthenticationErrorAction).payload.message,
blocking: false,
loading: false
});
@@ -132,25 +141,39 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut
error: (action as LogOutErrorAction).payload.message
});
case AuthActionTypes.LOG_OUT_SUCCESS:
case AuthActionTypes.REFRESH_TOKEN_ERROR:
return Object.assign({}, state, {
authenticated: false,
authToken: undefined,
error: undefined,
loaded: false,
blocking: false,
loading: false,
info: undefined,
refreshing: false,
userId: undefined
});
case AuthActionTypes.LOG_OUT_SUCCESS:
return Object.assign({}, state, {
authenticated: false,
authToken: undefined,
error: undefined,
loaded: false,
blocking: true,
loading: true,
info: undefined,
refreshing: false,
userId: undefined
});
case AuthActionTypes.REDIRECT_AUTHENTICATION_REQUIRED:
case AuthActionTypes.REDIRECT_TOKEN_EXPIRED:
return Object.assign({}, state, {
authenticated: false,
authToken: undefined,
loaded: false,
blocking: false,
loading: false,
info: (action as RedirectWhenTokenExpiredAction as RedirectWhenAuthenticationIsRequiredAction).payload,
userId: undefined
@@ -181,18 +204,21 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut
// next three cases are used by dynamic rendering of login methods
case AuthActionTypes.RETRIEVE_AUTH_METHODS:
return Object.assign({}, state, {
loading: true
loading: true,
blocking: true
});
case AuthActionTypes.RETRIEVE_AUTH_METHODS_SUCCESS:
return Object.assign({}, state, {
loading: false,
blocking: false,
authMethods: (action as RetrieveAuthMethodsSuccessAction).payload
});
case AuthActionTypes.RETRIEVE_AUTH_METHODS_ERROR:
return Object.assign({}, state, {
loading: false,
blocking: false,
authMethods: [new AuthMethod(AuthMethodType.Password)]
});
@@ -201,6 +227,12 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut
redirectUrl: (action as SetRedirectUrlAction).payload,
});
case AuthActionTypes.REDIRECT_AFTER_LOGIN_SUCCESS:
return Object.assign({}, state, {
loading: true,
blocking: true,
});
default:
return state;
}

View File

@@ -27,6 +27,7 @@ import { EPersonDataService } from '../eperson/eperson-data.service';
import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
import { authMethodsMock } from '../../shared/testing/auth-service.stub';
import { AuthMethod } from './models/auth.method';
import { HardRedirectService } from '../services/hard-redirect.service';
describe('AuthService test', () => {
@@ -48,6 +49,7 @@ describe('AuthService test', () => {
let authenticatedState;
let unAuthenticatedState;
let linkService;
let hardRedirectService;
function init() {
mockStore = jasmine.createSpyObj('store', {
@@ -77,6 +79,7 @@ describe('AuthService test', () => {
linkService = {
resolveLinks: {}
};
hardRedirectService = jasmine.createSpyObj('hardRedirectService', ['redirect']);
spyOn(linkService, 'resolveLinks').and.returnValue({ authenticated: true, eperson: observableOf({ payload: {} }) });
}
@@ -104,6 +107,7 @@ describe('AuthService test', () => {
{ provide: ActivatedRoute, useValue: routeStub },
{ provide: Store, useValue: mockStore },
{ provide: EPersonDataService, useValue: mockEpersonDataService },
{ provide: HardRedirectService, useValue: hardRedirectService },
CookieService,
AuthService
],
@@ -210,7 +214,7 @@ describe('AuthService test', () => {
(state as any).core = Object.create({});
(state as any).core.auth = authenticatedState;
});
authService = new AuthService({}, window, undefined, authReqService, mockEpersonDataService, router, routeService, cookieService, store);
authService = new AuthService({}, window, undefined, authReqService, mockEpersonDataService, router, routeService, cookieService, store, hardRedirectService);
}));
it('should return true when user is logged in', () => {
@@ -289,7 +293,7 @@ describe('AuthService test', () => {
(state as any).core = Object.create({});
(state as any).core.auth = authenticatedState;
});
authService = new AuthService({}, window, undefined, authReqService, mockEpersonDataService, router, routeService, cookieService, store);
authService = new AuthService({}, window, undefined, authReqService, mockEpersonDataService, router, routeService, cookieService, store, hardRedirectService);
storage = (authService as any).storage;
routeServiceMock = TestBed.get(RouteService);
routerStub = TestBed.get(Router);
@@ -318,36 +322,28 @@ describe('AuthService test', () => {
expect(storage.remove).toHaveBeenCalled();
});
it('should set redirect url to previous page', () => {
spyOn(routeServiceMock, 'getHistory').and.callThrough();
spyOn(routerStub, 'navigateByUrl');
authService.redirectAfterLoginSuccess(true);
expect(routeServiceMock.getHistory).toHaveBeenCalled();
expect(routerStub.navigateByUrl).toHaveBeenCalledWith('/collection/123');
it('should redirect to reload with redirect url', () => {
authService.navigateToRedirectUrl('/collection/123');
// Reload with redirect URL set to /collection/123
expect(hardRedirectService.redirect).toHaveBeenCalledWith(jasmine.stringMatching(new RegExp('/reload/[0-9]*\\?redirect=' + encodeURIComponent('/collection/123'))));
});
it('should set redirect url to current page', () => {
spyOn(routeServiceMock, 'getHistory').and.callThrough();
spyOn(routerStub, 'navigateByUrl');
authService.redirectAfterLoginSuccess(false);
expect(routeServiceMock.getHistory).toHaveBeenCalled();
expect(routerStub.navigateByUrl).toHaveBeenCalledWith('/home');
it('should redirect to reload with /home', () => {
authService.navigateToRedirectUrl('/home');
// Reload with redirect URL set to /home
expect(hardRedirectService.redirect).toHaveBeenCalledWith(jasmine.stringMatching(new RegExp('/reload/[0-9]*\\?redirect=' + encodeURIComponent('/home'))));
});
it('should redirect to / and not to /login', () => {
spyOn(routeServiceMock, 'getHistory').and.returnValue(observableOf(['/login', '/login']));
spyOn(routerStub, 'navigateByUrl');
authService.redirectAfterLoginSuccess(true);
expect(routeServiceMock.getHistory).toHaveBeenCalled();
expect(routerStub.navigateByUrl).toHaveBeenCalledWith('/');
it('should redirect to regular reload and not to /login', () => {
authService.navigateToRedirectUrl('/login');
// Reload without a redirect URL
expect(hardRedirectService.redirect).toHaveBeenCalledWith(jasmine.stringMatching(new RegExp('/reload/[0-9]*(?!\\?)$')));
});
it('should redirect to / when no redirect url is found', () => {
spyOn(routeServiceMock, 'getHistory').and.returnValue(observableOf(['']));
spyOn(routerStub, 'navigateByUrl');
authService.redirectAfterLoginSuccess(true);
expect(routeServiceMock.getHistory).toHaveBeenCalled();
expect(routerStub.navigateByUrl).toHaveBeenCalledWith('/');
it('should redirect to regular reload when no redirect url is found', () => {
authService.navigateToRedirectUrl(undefined);
// Reload without a redirect URL
expect(hardRedirectService.redirect).toHaveBeenCalledWith(jasmine.stringMatching(new RegExp('/reload/[0-9]*(?!\\?)$')));
});
describe('impersonate', () => {
@@ -464,6 +460,14 @@ describe('AuthService test', () => {
});
});
});
describe('refreshAfterLogout', () => {
it('should call navigateToRedirectUrl with no url', () => {
spyOn(authService as any, 'navigateToRedirectUrl').and.stub();
authService.refreshAfterLogout();
expect((authService as any).navigateToRedirectUrl).toHaveBeenCalled();
});
});
});
describe('when user is not logged in', () => {
@@ -496,7 +500,7 @@ describe('AuthService test', () => {
(state as any).core = Object.create({});
(state as any).core.auth = unAuthenticatedState;
});
authService = new AuthService({}, window, undefined, authReqService, mockEpersonDataService, router, routeService, cookieService, store);
authService = new AuthService({}, window, undefined, authReqService, mockEpersonDataService, router, routeService, cookieService, store, hardRedirectService);
}));
it('should return null for the shortlived token', () => {

View File

@@ -1,11 +1,10 @@
import { Inject, Injectable, Optional } from '@angular/core';
import { PRIMARY_OUTLET, Router, UrlSegmentGroup, UrlTree } from '@angular/router';
import { Router } from '@angular/router';
import { HttpHeaders } from '@angular/common/http';
import { REQUEST, RESPONSE } from '@nguniversal/express-engine/tokens';
import { Observable, of as observableOf } from 'rxjs';
import { distinctUntilChanged, filter, map, startWith, switchMap, take, withLatestFrom } from 'rxjs/operators';
import { RouterReducerState } from '@ngrx/router-store';
import { map, startWith, switchMap, take } from 'rxjs/operators';
import { select, Store } from '@ngrx/store';
import { CookieAttributes } from 'js-cookie';
@@ -14,7 +13,15 @@ import { AuthRequestService } from './auth-request.service';
import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service';
import { AuthStatus } from './models/auth-status.model';
import { AuthTokenInfo, TOKENITEM } from './models/auth-token-info.model';
import { hasValue, hasValueOperator, isEmpty, isNotEmpty, isNotNull, isNotUndefined } from '../../shared/empty.util';
import {
hasValue,
hasValueOperator,
isEmpty,
isNotEmpty,
isNotNull,
isNotUndefined,
hasNoValue
} from '../../shared/empty.util';
import { CookieService } from '../services/cookie.service';
import {
getAuthenticatedUserId,
@@ -24,7 +31,7 @@ import {
isTokenRefreshing,
isAuthenticatedLoaded
} from './selectors';
import { AppState, routerStateSelector } from '../../app.reducer';
import { AppState } from '../../app.reducer';
import {
CheckAuthenticationTokenAction,
ResetAuthenticationMessagesAction,
@@ -36,6 +43,7 @@ import { RouteService } from '../services/route.service';
import { EPersonDataService } from '../eperson/eperson-data.service';
import { getAllSucceededRemoteDataPayload } from '../shared/operators';
import { AuthMethod } from './models/auth.method';
import { HardRedirectService } from '../services/hard-redirect.service';
export const LOGIN_ROUTE = '/login';
export const LOGOUT_ROUTE = '/logout';
@@ -62,43 +70,13 @@ export class AuthService {
protected router: Router,
protected routeService: RouteService,
protected storage: CookieService,
protected store: Store<AppState>
protected store: Store<AppState>,
protected hardRedirectService: HardRedirectService
) {
this.store.pipe(
select(isAuthenticated),
startWith(false)
).subscribe((authenticated: boolean) => this._authenticated = authenticated);
// If current route is different from the one setted in authentication guard
// and is not the login route, clear redirect url and messages
const routeUrl$ = this.store.pipe(
select(routerStateSelector),
filter((routerState: RouterReducerState) => isNotUndefined(routerState)
&& isNotUndefined(routerState.state) && isNotEmpty(routerState.state.url)),
filter((routerState: RouterReducerState) => !this.isLoginRoute(routerState.state.url)),
map((routerState: RouterReducerState) => routerState.state.url)
);
const redirectUrl$ = this.store.pipe(select(getRedirectUrl), distinctUntilChanged());
routeUrl$.pipe(
withLatestFrom(redirectUrl$),
map(([routeUrl, redirectUrl]) => [routeUrl, redirectUrl])
).pipe(filter(([routeUrl, redirectUrl]) => isNotEmpty(redirectUrl) && (routeUrl !== redirectUrl)))
.subscribe(() => {
this.clearRedirectUrl();
});
}
/**
* Check if is a login page route
*
* @param {string} url
* @returns {Boolean}.
*/
protected isLoginRoute(url: string) {
const urlTree: UrlTree = this.router.parseUrl(url);
const g: UrlSegmentGroup = urlTree.root.children[PRIMARY_OUTLET];
const segment = '/' + g.toString();
return segment === LOGIN_ROUTE;
}
/**
@@ -409,69 +387,38 @@ export class AuthService {
}
/**
* Redirect to the route navigated before the login
* Perform a hard redirect to the URL
* @param redirectUrl
*/
public redirectAfterLoginSuccess(isStandalonePage: boolean) {
this.getRedirectUrl().pipe(
take(1))
.subscribe((redirectUrl) => {
if (isNotEmpty(redirectUrl)) {
this.clearRedirectUrl();
this.router.onSameUrlNavigation = 'reload';
this.navigateToRedirectUrl(redirectUrl);
} else {
// If redirectUrl is empty use history.
this.routeService.getHistory().pipe(
take(1)
).subscribe((history) => {
let redirUrl;
if (isStandalonePage) {
// For standalone login pages, use the previous route.
redirUrl = history[history.length - 2] || '';
} else {
redirUrl = history[history.length - 1] || '';
}
this.navigateToRedirectUrl(redirUrl);
});
}
});
}
protected navigateToRedirectUrl(redirectUrl: string) {
const url = decodeURIComponent(redirectUrl);
// in case the user navigates directly to /login (via bookmark, etc), or the route history is not found.
if (isEmpty(url) || url.startsWith(LOGIN_ROUTE)) {
this.router.navigateByUrl('/');
/* TODO Reenable hard redirect when REST API can handle x-forwarded-for, see https://github.com/DSpace/DSpace/pull/2207 */
// this._window.nativeWindow.location.href = '/';
} else {
/* TODO Reenable hard redirect when REST API can handle x-forwarded-for, see https://github.com/DSpace/DSpace/pull/2207 */
// this._window.nativeWindow.location.href = url;
this.router.navigateByUrl(url);
public navigateToRedirectUrl(redirectUrl: string) {
let url = `/reload/${new Date().getTime()}`;
if (isNotEmpty(redirectUrl) && !redirectUrl.startsWith(LOGIN_ROUTE)) {
url += `?redirect=${encodeURIComponent(redirectUrl)}`;
}
this.hardRedirectService.redirect(url);
}
/**
* Refresh route navigated
*/
public refreshAfterLogout() {
// Hard redirect to the reload page with a unique number behind it
// so that all state is definitely lost
this._window.nativeWindow.location.href = `/reload/${new Date().getTime()}`;
this.navigateToRedirectUrl(undefined);
}
/**
* Get redirect url
*/
getRedirectUrl(): Observable<string> {
const redirectUrl = this.storage.get(REDIRECT_COOKIE);
if (isNotEmpty(redirectUrl)) {
return observableOf(redirectUrl);
} else {
return this.store.pipe(select(getRedirectUrl));
}
return this.store.pipe(
select(getRedirectUrl),
map((urlFromStore: string) => {
if (hasValue(urlFromStore)) {
return urlFromStore;
} else {
return this.storage.get(REDIRECT_COOKIE);
}
})
);
}
/**
@@ -488,6 +435,20 @@ export class AuthService {
this.store.dispatch(new SetRedirectUrlAction(isNotUndefined(url) ? url : ''));
}
/**
* Set the redirect url if the current one has not been set yet
* @param newRedirectUrl
*/
setRedirectUrlIfNotSet(newRedirectUrl: string) {
this.getRedirectUrl().pipe(
take(1))
.subscribe((currentRedirectUrl) => {
if (hasNoValue(currentRedirectUrl)) {
this.setRedirectUrl(newRedirectUrl);
}
})
}
/**
* Clear redirect url
*/

View File

@@ -1,21 +1,26 @@
import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, CanActivate, CanLoad, Route, Router, RouterStateSnapshot } from '@angular/router';
import {
ActivatedRouteSnapshot,
CanActivate,
Router,
RouterStateSnapshot,
UrlTree
} from '@angular/router';
import { Observable } from 'rxjs';
import { take } from 'rxjs/operators';
import { map, find, switchMap } from 'rxjs/operators';
import { select, Store } from '@ngrx/store';
import { CoreState } from '../core.reducers';
import { isAuthenticated } from './selectors';
import { AuthService } from './auth.service';
import { RedirectWhenAuthenticationIsRequiredAction } from './auth.actions';
import { isAuthenticated, isAuthenticationLoading } from './selectors';
import { AuthService, LOGIN_ROUTE } from './auth.service';
/**
* Prevent unauthorized activating and loading of routes
* @class AuthenticatedGuard
*/
@Injectable()
export class AuthenticatedGuard implements CanActivate, CanLoad {
export class AuthenticatedGuard implements CanActivate {
/**
* @constructor
@@ -24,46 +29,37 @@ export class AuthenticatedGuard implements CanActivate, CanLoad {
/**
* True when user is authenticated
* UrlTree with redirect to login page when user isn't authenticated
* @method canActivate
*/
canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<boolean> {
canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<boolean | UrlTree> {
const url = state.url;
return this.handleAuth(url);
}
/**
* True when user is authenticated
* UrlTree with redirect to login page when user isn't authenticated
* @method canActivateChild
*/
canActivateChild(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<boolean> {
canActivateChild(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<boolean | UrlTree> {
return this.canActivate(route, state);
}
/**
* True when user is authenticated
* @method canLoad
*/
canLoad(route: Route): Observable<boolean> {
const url = `/${route.path}`;
return this.handleAuth(url);
}
private handleAuth(url: string): Observable<boolean> {
// get observable
const observable = this.store.pipe(select(isAuthenticated));
private handleAuth(url: string): Observable<boolean | UrlTree> {
// redirect to sign in page if user is not authenticated
observable.pipe(
// .filter(() => isEmpty(this.router.routerState.snapshot.url) || this.router.routerState.snapshot.url === url)
take(1))
.subscribe((authenticated) => {
if (!authenticated) {
return this.store.pipe(select(isAuthenticationLoading)).pipe(
find((isLoading: boolean) => isLoading === false),
switchMap(() => this.store.pipe(select(isAuthenticated))),
map((authenticated) => {
if (authenticated) {
return authenticated;
} else {
this.authService.setRedirectUrl(url);
this.store.dispatch(new RedirectWhenAuthenticationIsRequiredAction('Login required'));
this.authService.removeToken();
return this.router.createUrlTree([LOGIN_ROUTE]);
}
});
return observable;
})
);
}
}

View File

@@ -65,6 +65,14 @@ const _getAuthenticationInfo = (state: AuthState) => state.info;
*/
const _isLoading = (state: AuthState) => state.loading;
/**
* Returns true if everything else should wait for authentication.
* @function _isBlocking
* @param {State} state
* @returns {boolean}
*/
const _isBlocking = (state: AuthState) => state.blocking;
/**
* Returns true if a refresh token request is in progress.
* @function _isRefreshing
@@ -170,6 +178,16 @@ export const isAuthenticatedLoaded = createSelector(getAuthState, _isAuthenticat
*/
export const isAuthenticationLoading = createSelector(getAuthState, _isLoading);
/**
* Returns true if the authentication should block everything else
*
* @function isAuthenticationBlocking
* @param {AuthState} state
* @param {any} props
* @return {boolean}
*/
export const isAuthenticationBlocking = createSelector(getAuthState, _isBlocking);
/**
* Returns true if the refresh token request is loading.
* @function isTokenRefreshing

View File

@@ -2,7 +2,7 @@ import { Injectable } from '@angular/core';
import { HttpHeaders } from '@angular/common/http';
import { Observable } from 'rxjs';
import { filter, map, take } from 'rxjs/operators';
import { map } from 'rxjs/operators';
import { isNotEmpty } from '../../shared/empty.util';
import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service';
@@ -58,32 +58,4 @@ export class ServerAuthService extends AuthService {
map((status: AuthStatus) => Object.assign(new AuthStatus(), status))
);
}
/**
* Redirect to the route navigated before the login
*/
public redirectAfterLoginSuccess(isStandalonePage: boolean) {
this.getRedirectUrl().pipe(
take(1))
.subscribe((redirectUrl) => {
if (isNotEmpty(redirectUrl)) {
// override the route reuse strategy
this.router.routeReuseStrategy.shouldReuseRoute = () => {
return false;
};
this.router.navigated = false;
const url = decodeURIComponent(redirectUrl);
this.router.navigateByUrl(url);
} else {
// If redirectUrl is empty use history. For ssr the history array should contain the requested url.
this.routeService.getHistory().pipe(
filter((history) => history.length > 0),
take(1)
).subscribe((history) => {
this.navigateToRedirectUrl(history[history.length - 1] || '');
});
}
})
}
}

View File

@@ -5,7 +5,6 @@ import { PageInfo } from '../shared/page-info.model';
import { ConfigObject } from '../config/models/config.model';
import { FacetValue } from '../../shared/search/facet-value.model';
import { SearchFilterConfig } from '../../shared/search/search-filter-config.model';
import { IntegrationModel } from '../integration/models/integration.model';
import { PaginatedList } from '../data/paginated-list';
import { SubmissionObject } from '../submission/models/submission-object.model';
import { DSpaceObject } from '../shared/dspace-object.model';
@@ -181,17 +180,6 @@ export class TokenResponse extends RestResponse {
}
}
export class IntegrationSuccessResponse extends RestResponse {
constructor(
public dataDefinition: PaginatedList<IntegrationModel>,
public statusCode: number,
public statusText: string,
public pageInfo?: PageInfo
) {
super(true, statusCode, statusText);
}
}
export class PostPatchSuccessResponse extends RestResponse {
constructor(
public dataDefinition: any,

View File

@@ -1,6 +1,7 @@
import { CommonModule } from '@angular/common';
import { HTTP_INTERCEPTORS, HttpClient } from '@angular/common/http';
import { ModuleWithProviders, NgModule, Optional, SkipSelf } from '@angular/core';
import { DynamicFormLayoutService, DynamicFormService, DynamicFormValidationService } from '@ng-dynamic-forms/core';
import { EffectsModule } from '@ngrx/effects';
@@ -15,8 +16,8 @@ import { MenuService } from '../shared/menu/menu.service';
import { EndpointMockingRestService } from '../shared/mocks/dspace-rest-v2/endpoint-mocking-rest.service';
import {
MOCK_RESPONSE_MAP,
ResponseMapMock,
mockResponseMap
mockResponseMap,
ResponseMapMock
} from '../shared/mocks/dspace-rest-v2/mocks/response-map.mock';
import { NotificationsService } from '../shared/notifications/notifications.service';
import { SelectableListService } from '../shared/object-list/selectable-list/selectable-list.service';
@@ -80,9 +81,6 @@ import { EPersonDataService } from './eperson/eperson-data.service';
import { EpersonResponseParsingService } from './eperson/eperson-response-parsing.service';
import { EPerson } from './eperson/models/eperson.model';
import { Group } from './eperson/models/group.model';
import { AuthorityService } from './integration/authority.service';
import { IntegrationResponseParsingService } from './integration/integration-response-parsing.service';
import { AuthorityValue } from './integration/models/authority.value';
import { JsonPatchOperationsBuilder } from './json-patch/builder/json-patch-operations-builder';
import { MetadataField } from './metadata/metadata-field.model';
import { MetadataSchema } from './metadata/metadata-schema.model';
@@ -160,8 +158,19 @@ import { SubmissionCcLicenseDataService } from './submission/submission-cc-licen
import { SubmissionCcLicence } from './submission/models/submission-cc-license.model';
import { SubmissionCcLicenceUrl } from './submission/models/submission-cc-license-url.model';
import { SubmissionCcLicenseUrlDataService } from './submission/submission-cc-license-url-data.service';
import { VocabularyEntry } from './submission/vocabularies/models/vocabulary-entry.model';
import { Vocabulary } from './submission/vocabularies/models/vocabulary.model';
import { VocabularyEntriesResponseParsingService } from './submission/vocabularies/vocabulary-entries-response-parsing.service';
import { VocabularyEntryDetail } from './submission/vocabularies/models/vocabulary-entry-detail.model';
import { VocabularyService } from './submission/vocabularies/vocabulary.service';
import { VocabularyTreeviewService } from '../shared/vocabulary-treeview/vocabulary-treeview.service';
import { ConfigurationDataService } from './data/configuration-data.service';
import { ConfigurationProperty } from './shared/configuration-property.model';
import { ReloadGuard } from './reload/reload.guard';
import { EndUserAgreementCurrentUserGuard } from './end-user-agreement/end-user-agreement-current-user.guard';
import { EndUserAgreementCookieGuard } from './end-user-agreement/end-user-agreement-cookie.guard';
import { EndUserAgreementService } from './end-user-agreement/end-user-agreement.service';
import { SiteRegisterGuard } from './data/feature-authorization/feature-authorization-guard/site-register.guard';
/**
* When not in production, endpoint responses can be mocked for testing purposes
@@ -195,7 +204,7 @@ const PROVIDERS = [
SiteDataService,
DSOResponseParsingService,
{ provide: MOCK_RESPONSE_MAP, useValue: mockResponseMap },
{ provide: DSpaceRESTv2Service, useFactory: restServiceFactory, deps: [MOCK_RESPONSE_MAP, HttpClient]},
{ provide: DSpaceRESTv2Service, useFactory: restServiceFactory, deps: [MOCK_RESPONSE_MAP, HttpClient] },
DynamicFormLayoutService,
DynamicFormService,
DynamicFormValidationService,
@@ -237,8 +246,6 @@ const PROVIDERS = [
SubmissionResponseParsingService,
SubmissionJsonPatchOperationsService,
JsonPatchOperationsBuilder,
AuthorityService,
IntegrationResponseParsingService,
UploaderService,
UUIDService,
NotificationsService,
@@ -286,9 +293,14 @@ const PROVIDERS = [
FeatureDataService,
AuthorizationDataService,
SiteAdministratorGuard,
SiteRegisterGuard,
MetadataSchemaDataService,
MetadataFieldDataService,
TokenResponseParsingService,
ReloadGuard,
EndUserAgreementCurrentUserGuard,
EndUserAgreementCookieGuard,
EndUserAgreementService,
// register AuthInterceptor as HttpInterceptor
{
provide: HTTP_INTERCEPTORS,
@@ -303,7 +315,10 @@ const PROVIDERS = [
},
NotificationsService,
FilteredDiscoveryPageResponseParsingService,
{ provide: NativeWindowService, useFactory: NativeWindowFactory }
{ provide: NativeWindowService, useFactory: NativeWindowFactory },
VocabularyService,
VocabularyEntriesResponseParsingService,
VocabularyTreeviewService
];
/**
@@ -334,7 +349,6 @@ export const models =
SubmissionSectionModel,
SubmissionUploadsModel,
AuthStatus,
AuthorityValue,
BrowseEntry,
BrowseDefinition,
ClaimedTask,
@@ -354,6 +368,9 @@ export const models =
Feature,
Authorization,
Registration,
Vocabulary,
VocabularyEntry,
VocabularyEntryDetail,
ConfigurationProperty
];

View File

@@ -1,40 +1,22 @@
import { Inject, Injectable } from '@angular/core';
import { isNotEmpty } from '../../shared/empty.util';
import { Injectable } from '@angular/core';
import { ObjectCacheService } from '../cache/object-cache.service';
import { ErrorResponse, GenericSuccessResponse, RestResponse } from '../cache/response.models';
import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model';
import { DSpaceSerializer } from '../dspace-rest-v2/dspace.serializer';
import { BrowseEntry } from '../shared/browse-entry.model';
import { BaseResponseParsingService } from './base-response-parsing.service';
import { ResponseParsingService } from './parsing.service';
import { RestRequest } from './request.models';
import { EntriesResponseParsingService } from './entries-response-parsing.service';
import { GenericConstructor } from '../shared/generic-constructor';
@Injectable()
export class BrowseEntriesResponseParsingService extends BaseResponseParsingService implements ResponseParsingService {
export class BrowseEntriesResponseParsingService extends EntriesResponseParsingService<BrowseEntry> {
protected toCache = false;
constructor(
protected objectCache: ObjectCacheService,
) { super();
) {
super(objectCache);
}
parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse {
if (isNotEmpty(data.payload)) {
let browseEntries = [];
if (isNotEmpty(data.payload._embedded) && Array.isArray(data.payload._embedded[Object.keys(data.payload._embedded)[0]])) {
const serializer = new DSpaceSerializer(BrowseEntry);
browseEntries = serializer.deserializeArray(data.payload._embedded[Object.keys(data.payload._embedded)[0]]);
}
return new GenericSuccessResponse(browseEntries, data.statusCode, data.statusText, this.processPageInfo(data.payload));
} else {
return new ErrorResponse(
Object.assign(
new Error('Unexpected response from browse endpoint'),
{ statusCode: data.statusCode, statusText: data.statusText }
)
);
}
getSerializerModel(): GenericConstructor<BrowseEntry> {
return BrowseEntry;
}
}

View File

@@ -1,21 +1,11 @@
import { HttpClient } from '@angular/common/http';
import { Store } from '@ngrx/store';
import { compare, Operation } from 'fast-json-patch';
import { Observable, of as observableOf } from 'rxjs';
import { NotificationsService } from '../../shared/notifications/notifications.service';
import { followLink } from '../../shared/utils/follow-link-config.model';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
import { SortDirection, SortOptions } from '../cache/models/sort-options.model';
import { ObjectCacheService } from '../cache/object-cache.service';
import { CoreState } from '../core.reducers';
import { DSpaceObject } from '../shared/dspace-object.model';
import { HALEndpointService } from '../shared/hal-endpoint.service';
import { Item } from '../shared/item.model';
import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
import { ChangeAnalyzer } from './change-analyzer';
import { DataService } from './data.service';
import { FindListOptions, PatchRequest } from './request.models';
import { RequestService } from './request.service';
import { getMockRequestService } from '../../shared/mocks/request.service.mock';
import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub';
import { BundleDataService } from './bundle-data.service';

View File

@@ -6,18 +6,18 @@ import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-servic
import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub';
import { getMockTranslateService } from '../../shared/mocks/translate.service.mock';
import { fakeAsync, tick } from '@angular/core/testing';
import { ContentSourceRequest, GetRequest, RequestError, UpdateContentSourceRequest } from './request.models';
import { ContentSourceRequest, GetRequest, UpdateContentSourceRequest } from './request.models';
import { ContentSource } from '../shared/content-source.model';
import { of as observableOf } from 'rxjs/internal/observable/of';
import { RequestEntry } from './request.reducer';
import { ErrorResponse, RestResponse } from '../cache/response.models';
import { ErrorResponse } from '../cache/response.models';
import { ObjectCacheService } from '../cache/object-cache.service';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
import { Collection } from '../shared/collection.model';
import { PageInfo } from '../shared/page-info.model';
import { PaginatedList } from './paginated-list';
import { createSuccessfulRemoteDataObject } from 'src/app/shared/remote-data.utils';
import { hot, getTestScheduler, cold } from 'jasmine-marbles';
import { cold, getTestScheduler, hot } from 'jasmine-marbles';
import { TestScheduler } from 'rxjs/testing';
const url = 'fake-url';

View File

@@ -24,7 +24,7 @@ import { RequestService } from './request.service';
@dataService(COMMUNITY)
export class CommunityDataService extends ComColDataService<Community> {
protected linkPath = 'communities';
protected topLinkPath = 'communities/search/top';
protected topLinkPath = 'search/top';
protected cds = this;
constructor(

View File

@@ -18,6 +18,7 @@ import { FindListOptions, PatchRequest } from './request.models';
import { RequestService } from './request.service';
import { getMockRequestService } from '../../shared/mocks/request.service.mock';
import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub';
import { RequestParam } from '../cache/models/request-param.model';
const endpoint = 'https://rest.api/core';
@@ -150,7 +151,8 @@ describe('DataService', () => {
currentPage: 6,
elementsPerPage: 10,
sort: sortOptions,
startsWith: 'ab'
startsWith: 'ab',
};
const expected = `${endpoint}?page=${options.currentPage - 1}&size=${options.elementsPerPage}` +
`&sort=${sortOptions.field},${sortOptions.direction}&startsWith=${options.startsWith}`;
@@ -160,6 +162,26 @@ describe('DataService', () => {
});
});
it('should include all searchParams in href if any provided in options', () => {
options = { searchParams: [
new RequestParam('param1', 'test'),
new RequestParam('param2', 'test2'),
] };
const expected = `${endpoint}?param1=test&param2=test2`;
(service as any).getFindAllHref(options).subscribe((value) => {
expect(value).toBe(expected);
});
});
it('should include linkPath in href if any provided', () => {
const expected = `${endpoint}/test/entries`;
(service as any).getFindAllHref({}, 'test/entries').subscribe((value) => {
expect(value).toBe(expected);
});
});
it('should include single linksToFollow as embed', () => {
const expected = `${endpoint}?embed=bundles`;

View File

@@ -3,7 +3,7 @@ import { Store } from '@ngrx/store';
import { Operation } from 'fast-json-patch';
import { Observable } from 'rxjs';
import { distinctUntilChanged, filter, find, first, map, mergeMap, switchMap, take } from 'rxjs/operators';
import { hasValue, isNotEmpty, isNotEmptyOperator } from '../../shared/empty.util';
import { hasValue, hasValueOperator, isNotEmpty, isNotEmptyOperator } from '../../shared/empty.util';
import { NotificationOptions } from '../../shared/notifications/models/notification-options.model';
import { NotificationsService } from '../../shared/notifications/notifications.service';
import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model';
@@ -71,13 +71,17 @@ export abstract class DataService<T extends CacheableObject> implements UpdateDa
* Return an observable that emits created HREF
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved
*/
protected getFindAllHref(options: FindListOptions = {}, linkPath?: string, ...linksToFollow: Array<FollowLinkConfig<T>>): Observable<string> {
let result$: Observable<string>;
public getFindAllHref(options: FindListOptions = {}, linkPath?: string, ...linksToFollow: Array<FollowLinkConfig<T>>): Observable<string> {
let endpoint$: Observable<string>;
const args = [];
result$ = this.getBrowseEndpoint(options, linkPath).pipe(distinctUntilChanged());
endpoint$ = this.getBrowseEndpoint(options).pipe(
filter((href: string) => isNotEmpty(href)),
map((href: string) => isNotEmpty(linkPath) ? `${href}/${linkPath}` : href),
distinctUntilChanged()
);
return result$.pipe(map((result: string) => this.buildHrefFromFindOptions(result, options, args, ...linksToFollow)));
return endpoint$.pipe(map((result: string) => this.buildHrefFromFindOptions(result, options, args, ...linksToFollow)));
}
/**
@@ -89,18 +93,12 @@ export abstract class DataService<T extends CacheableObject> implements UpdateDa
* Return an observable that emits created HREF
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved
*/
protected getSearchByHref(searchMethod: string, options: FindListOptions = {}, ...linksToFollow: Array<FollowLinkConfig<T>>): Observable<string> {
public getSearchByHref(searchMethod: string, options: FindListOptions = {}, ...linksToFollow: Array<FollowLinkConfig<T>>): Observable<string> {
let result$: Observable<string>;
const args = [];
result$ = this.getSearchEndpoint(searchMethod);
if (hasValue(options.searchParams)) {
options.searchParams.forEach((param: RequestParam) => {
args.push(`${param.fieldName}=${param.fieldValue}`);
})
}
return result$.pipe(map((result: string) => this.buildHrefFromFindOptions(result, options, args, ...linksToFollow)));
}
@@ -114,7 +112,7 @@ export abstract class DataService<T extends CacheableObject> implements UpdateDa
* Return an observable that emits created HREF
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved
*/
protected buildHrefFromFindOptions(href: string, options: FindListOptions, extraArgs: string[] = [], ...linksToFollow: Array<FollowLinkConfig<T>>): string {
public buildHrefFromFindOptions(href: string, options: FindListOptions, extraArgs: string[] = [], ...linksToFollow: Array<FollowLinkConfig<T>>): string {
let args = [...extraArgs];
if (hasValue(options.currentPage) && typeof options.currentPage === 'number') {
@@ -130,6 +128,11 @@ export abstract class DataService<T extends CacheableObject> implements UpdateDa
if (hasValue(options.startsWith)) {
args = [...args, `startsWith=${options.startsWith}`];
}
if (hasValue(options.searchParams)) {
options.searchParams.forEach((param: RequestParam) => {
args = [...args, `${param.fieldName}=${param.fieldValue}`];
})
}
args = this.addEmbedParams(args, ...linksToFollow);
if (isNotEmpty(args)) {
return new URLCombiner(href, `?${args.join('&')}`).toString();
@@ -373,11 +376,20 @@ export abstract class DataService<T extends CacheableObject> implements UpdateDa
).subscribe();
return this.requestService.getByUUID(requestId).pipe(
hasValueOperator(),
find((request: RequestEntry) => request.completed),
map((request: RequestEntry) => request.response)
);
}
createPatchFromCache(object: T): Observable<Operation[]> {
const oldVersion$ = this.findByHref(object._links.self.href);
return oldVersion$.pipe(
getSucceededRemoteData(),
getRemoteDataPayload(),
map((oldVersion: T) => this.comparator.diff(oldVersion, object)));
}
/**
* Send a PUT request for the specified object
*
@@ -406,18 +418,16 @@ export abstract class DataService<T extends CacheableObject> implements UpdateDa
* @param {DSpaceObject} object The given object
*/
update(object: T): Observable<RemoteData<T>> {
const oldVersion$ = this.findByHref(object._links.self.href);
return oldVersion$.pipe(
getSucceededRemoteData(),
getRemoteDataPayload(),
mergeMap((oldVersion: T) => {
const operations = this.comparator.diff(oldVersion, object);
if (isNotEmpty(operations)) {
this.objectCache.addPatch(object._links.self.href, operations);
return this.createPatchFromCache(object)
.pipe(
mergeMap((operations: Operation[]) => {
if (isNotEmpty(operations)) {
this.objectCache.addPatch(object._links.self.href, operations);
}
return this.findByHref(object._links.self.href);
}
return this.findByHref(object._links.self.href);
}
));
)
);
}
/**

View File

@@ -0,0 +1,54 @@
import { isNotEmpty } from '../../shared/empty.util';
import { ObjectCacheService } from '../cache/object-cache.service';
import { ErrorResponse, GenericSuccessResponse, RestResponse } from '../cache/response.models';
import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model';
import { DSpaceSerializer } from '../dspace-rest-v2/dspace.serializer';
import { BaseResponseParsingService } from './base-response-parsing.service';
import { ResponseParsingService } from './parsing.service';
import { RestRequest } from './request.models';
import { CacheableObject } from '../cache/object-cache.reducer';
import { GenericConstructor } from '../shared/generic-constructor';
/**
* An abstract class to extend, responsible for parsing data for an entries response
*/
export abstract class EntriesResponseParsingService<T extends CacheableObject> extends BaseResponseParsingService implements ResponseParsingService {
protected toCache = false;
constructor(
protected objectCache: ObjectCacheService,
) {
super();
}
/**
* Abstract method to implement that must return the dspace serializer Constructor to use during parse
*/
abstract getSerializerModel(): GenericConstructor<T>;
/**
* Parse response
*
* @param request
* @param data
*/
parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse {
if (isNotEmpty(data.payload)) {
let entries = [];
if (isNotEmpty(data.payload._embedded) && Array.isArray(data.payload._embedded[Object.keys(data.payload._embedded)[0]])) {
const serializer = new DSpaceSerializer(this.getSerializerModel());
entries = serializer.deserializeArray(data.payload._embedded[Object.keys(data.payload._embedded)[0]]);
}
return new GenericSuccessResponse(entries, data.statusCode, data.statusText, this.processPageInfo(data.payload));
} else {
return new ErrorResponse(
Object.assign(
new Error('Unexpected response from browse endpoint'),
{ statusCode: data.statusCode, statusText: data.statusText }
)
);
}
}
}

View File

@@ -63,33 +63,33 @@ describe('AuthorizationDataService', () => {
return Object.assign(new FindListOptions(), { searchParams });
}
describe('when no arguments are provided and a user is authenticated', () => {
describe('when no arguments are provided', () => {
beforeEach(() => {
service.searchByObject().subscribe();
});
it('should call searchBy with the site\'s url and authenticated user\'s uuid', () => {
expect(service.searchBy).toHaveBeenCalledWith('object', createExpected(site.self, ePerson.uuid));
it('should call searchBy with the site\'s url', () => {
expect(service.searchBy).toHaveBeenCalledWith('object', createExpected(site.self));
});
});
describe('when no arguments except for a feature are provided and a user is authenticated', () => {
describe('when no arguments except for a feature are provided', () => {
beforeEach(() => {
service.searchByObject(FeatureID.LoginOnBehalfOf).subscribe();
});
it('should call searchBy with the site\'s url, authenticated user\'s uuid and the feature', () => {
expect(service.searchBy).toHaveBeenCalledWith('object', createExpected(site.self, ePerson.uuid, FeatureID.LoginOnBehalfOf));
it('should call searchBy with the site\'s url and the feature', () => {
expect(service.searchBy).toHaveBeenCalledWith('object', createExpected(site.self, null, FeatureID.LoginOnBehalfOf));
});
});
describe('when a feature and object url are provided, but no user uuid and a user is authenticated', () => {
describe('when a feature and object url are provided', () => {
beforeEach(() => {
service.searchByObject(FeatureID.LoginOnBehalfOf, objectUrl).subscribe();
});
it('should call searchBy with the object\'s url, authenticated user\'s uuid and the feature', () => {
expect(service.searchBy).toHaveBeenCalledWith('object', createExpected(objectUrl, ePerson.uuid, FeatureID.LoginOnBehalfOf));
it('should call searchBy with the object\'s url and the feature', () => {
expect(service.searchBy).toHaveBeenCalledWith('object', createExpected(objectUrl, null, FeatureID.LoginOnBehalfOf));
});
});
@@ -102,17 +102,6 @@ describe('AuthorizationDataService', () => {
expect(service.searchBy).toHaveBeenCalledWith('object', createExpected(objectUrl, ePersonUuid, FeatureID.LoginOnBehalfOf));
});
});
describe('when no arguments are provided and no user is authenticated', () => {
beforeEach(() => {
spyOn(authService, 'isAuthenticated').and.returnValue(observableOf(false));
service.searchByObject().subscribe();
});
it('should call searchBy with the site\'s url', () => {
expect(service.searchBy).toHaveBeenCalledWith('object', createExpected(site.self));
});
});
});
describe('isAuthorized', () => {

View File

@@ -25,7 +25,6 @@ import { hasValue, isNotEmpty } from '../../../shared/empty.util';
import { RequestParam } from '../../cache/models/request-param.model';
import { AuthorizationSearchParams } from './authorization-search-params';
import {
addAuthenticatedUserUuidIfEmpty,
addSiteObjectUrlIfEmpty,
oneAuthorizationMatchesFeature
} from './authorization-utils';
@@ -90,7 +89,6 @@ export class AuthorizationDataService extends DataService<Authorization> {
searchByObject(featureId?: FeatureID, objectUrl?: string, ePersonUuid?: string, options: FindListOptions = {}, ...linksToFollow: Array<FollowLinkConfig<Authorization>>): Observable<RemoteData<PaginatedList<Authorization>>> {
return observableOf(new AuthorizationSearchParams(objectUrl, ePersonUuid, featureId)).pipe(
addSiteObjectUrlIfEmpty(this.siteService),
addAuthenticatedUserUuidIfEmpty(this.authService),
switchMap((params: AuthorizationSearchParams) => {
return this.searchBy(this.searchByObjectPath, this.createSearchOptions(params.objectUrl, options, params.ePersonUuid, params.featureId), ...linksToFollow);
})

View File

@@ -0,0 +1,63 @@
import { AuthorizationDataService } from '../authorization-data.service';
import { ActivatedRouteSnapshot, Resolve, Router, RouterStateSnapshot } from '@angular/router';
import { RemoteData } from '../../remote-data';
import { of as observableOf } from 'rxjs';
import { createSuccessfulRemoteDataObject$ } from '../../../../shared/remote-data.utils';
import { DSpaceObject } from '../../../shared/dspace-object.model';
import { DsoPageFeatureGuard } from './dso-page-feature.guard';
import { FeatureID } from '../feature-id';
import { Observable } from 'rxjs/internal/Observable';
/**
* Test implementation of abstract class DsoPageAdministratorGuard
*/
class DsoPageFeatureGuardImpl extends DsoPageFeatureGuard<any> {
constructor(protected resolver: Resolve<RemoteData<any>>,
protected authorizationService: AuthorizationDataService,
protected router: Router,
protected featureID: FeatureID) {
super(resolver, authorizationService, router);
}
getFeatureID(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<FeatureID> {
return observableOf(this.featureID);
}
}
describe('DsoPageAdministratorGuard', () => {
let guard: DsoPageFeatureGuard<any>;
let authorizationService: AuthorizationDataService;
let router: Router;
let resolver: Resolve<RemoteData<any>>;
let object: DSpaceObject;
function init() {
object = {
self: 'test-selflink'
} as DSpaceObject;
authorizationService = jasmine.createSpyObj('authorizationService', {
isAuthorized: observableOf(true)
});
router = jasmine.createSpyObj('router', {
parseUrl: {}
});
resolver = jasmine.createSpyObj('resolver', {
resolve: createSuccessfulRemoteDataObject$(object)
});
guard = new DsoPageFeatureGuardImpl(resolver, authorizationService, router, undefined);
}
beforeEach(() => {
init();
});
describe('getObjectUrl', () => {
it('should return the resolved object\'s selflink', (done) => {
guard.getObjectUrl(undefined, undefined).subscribe((selflink) => {
expect(selflink).toEqual(object.self);
done();
});
});
});
});

View File

@@ -0,0 +1,30 @@
import { ActivatedRouteSnapshot, Resolve, Router, RouterStateSnapshot } from '@angular/router';
import { RemoteData } from '../../remote-data';
import { AuthorizationDataService } from '../authorization-data.service';
import { Observable } from 'rxjs/internal/Observable';
import { getAllSucceededRemoteDataPayload } from '../../../shared/operators';
import { map } from 'rxjs/operators';
import { DSpaceObject } from '../../../shared/dspace-object.model';
import { FeatureAuthorizationGuard } from './feature-authorization.guard';
/**
* Abstract Guard for preventing unauthorized access to {@link DSpaceObject} pages that require rights for a specific feature
* This guard utilizes a resolver to retrieve the relevant object to check authorizations for
*/
export abstract class DsoPageFeatureGuard<T extends DSpaceObject> extends FeatureAuthorizationGuard {
constructor(protected resolver: Resolve<RemoteData<T>>,
protected authorizationService: AuthorizationDataService,
protected router: Router) {
super(authorizationService, router);
}
/**
* Check authorization rights for the object resolved using the provided resolver
*/
getObjectUrl(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<string> {
return (this.resolver.resolve(route, state) as Observable<RemoteData<T>>).pipe(
getAllSucceededRemoteDataPayload(),
map((dso) => dso.self)
);
}
}

View File

@@ -2,7 +2,8 @@ import { FeatureAuthorizationGuard } from './feature-authorization.guard';
import { AuthorizationDataService } from '../authorization-data.service';
import { FeatureID } from '../feature-id';
import { of as observableOf } from 'rxjs';
import { Router } from '@angular/router';
import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router';
import { Observable } from 'rxjs/internal/Observable';
/**
* Test implementation of abstract class FeatureAuthorizationGuard
@@ -17,16 +18,16 @@ class FeatureAuthorizationGuardImpl extends FeatureAuthorizationGuard {
super(authorizationService, router);
}
getFeatureID(): FeatureID {
return this.featureId;
getFeatureID(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<FeatureID> {
return observableOf(this.featureId);
}
getObjectUrl(): string {
return this.objectUrl;
getObjectUrl(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<string> {
return observableOf(this.objectUrl);
}
getEPersonUuid(): string {
return this.ePersonUuid;
getEPersonUuid(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<string> {
return observableOf(this.ePersonUuid);
}
}

View File

@@ -9,6 +9,8 @@ import { AuthorizationDataService } from '../authorization-data.service';
import { FeatureID } from '../feature-id';
import { Observable } from 'rxjs/internal/Observable';
import { returnUnauthorizedUrlTreeOnFalse } from '../../../shared/operators';
import { combineLatest as observableCombineLatest, of as observableOf } from 'rxjs';
import { switchMap } from 'rxjs/operators';
/**
* Abstract Guard for preventing unauthorized activating and loading of routes when a user
@@ -24,29 +26,32 @@ export abstract class FeatureAuthorizationGuard implements CanActivate {
* True when user has authorization rights for the feature and object provided
* Redirect the user to the unauthorized page when he/she's not authorized for the given feature
*/
canActivate(next: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<boolean | UrlTree> {
return this.authorizationService.isAuthorized(this.getFeatureID(), this.getObjectUrl(), this.getEPersonUuid()).pipe(returnUnauthorizedUrlTreeOnFalse(this.router));
canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<boolean | UrlTree> {
return observableCombineLatest(this.getFeatureID(route, state), this.getObjectUrl(route, state), this.getEPersonUuid(route, state)).pipe(
switchMap(([featureID, objectUrl, ePersonUuid]) => this.authorizationService.isAuthorized(featureID, objectUrl, ePersonUuid)),
returnUnauthorizedUrlTreeOnFalse(this.router)
);
}
/**
* The type of feature to check authorization for
* Override this method to define a feature
*/
abstract getFeatureID(): FeatureID;
abstract getFeatureID(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<FeatureID>;
/**
* The URL of the object to check if the user has authorized rights for
* Override this method to define an object URL. If not provided, the {@link Site}'s URL will be used
*/
getObjectUrl(): string {
return undefined;
getObjectUrl(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<string> {
return observableOf(undefined);
}
/**
* The UUID of the user to check authorization rights for
* Override this method to define an {@link EPerson} UUID. If not provided, the authenticated user's UUID will be used.
*/
getEPersonUuid(): string {
return undefined;
getEPersonUuid(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<string> {
return observableOf(undefined);
}
}

View File

@@ -2,7 +2,9 @@ import { Injectable } from '@angular/core';
import { FeatureAuthorizationGuard } from './feature-authorization.guard';
import { FeatureID } from '../feature-id';
import { AuthorizationDataService } from '../authorization-data.service';
import { Router } from '@angular/router';
import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router';
import { of as observableOf } from 'rxjs';
import { Observable } from 'rxjs/internal/Observable';
/**
* Prevent unauthorized activating and loading of routes when the current authenticated user doesn't have administrator
@@ -19,7 +21,7 @@ export class SiteAdministratorGuard extends FeatureAuthorizationGuard {
/**
* Check administrator authorization rights
*/
getFeatureID(): FeatureID {
return FeatureID.AdministratorOf;
getFeatureID(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<FeatureID> {
return observableOf(FeatureID.AdministratorOf);
}
}

View File

@@ -0,0 +1,27 @@
import { FeatureAuthorizationGuard } from './feature-authorization.guard';
import { Injectable } from '@angular/core';
import { AuthorizationDataService } from '../authorization-data.service';
import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router';
import { Observable } from 'rxjs/internal/Observable';
import { FeatureID } from '../feature-id';
import { of as observableOf } from 'rxjs';
/**
* Prevent unauthorized activating and loading of routes when the current authenticated user doesn't have registration
* rights to the {@link Site}
*/
@Injectable({
providedIn: 'root'
})
export class SiteRegisterGuard extends FeatureAuthorizationGuard {
constructor(protected authorizationService: AuthorizationDataService, protected router: Router) {
super(authorizationService, router);
}
/**
* Check registration authorization rights
*/
getFeatureID(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<FeatureID> {
return observableOf(FeatureID.EPersonRegistration);
}
}

View File

@@ -3,5 +3,8 @@
*/
export enum FeatureID {
LoginOnBehalfOf = 'loginOnBehalfOf',
AdministratorOf = 'administratorOf'
AdministratorOf = 'administratorOf',
WithdrawItem = 'withdrawItem',
ReinstateItem = 'reinstateItem',
EPersonRegistration = 'epersonRegistration',
}

View File

@@ -1,6 +1,9 @@
import { Injectable } from '@angular/core';
import { hasValue } from '../../shared/empty.util';
import { dataService } from '../cache/builders/build-decorators';
import { DataService } from './data.service';
import { PaginatedList } from './paginated-list';
import { RemoteData } from './remote-data';
import { RequestService } from './request.service';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
import { Store } from '@ngrx/store';
@@ -27,6 +30,7 @@ import { RequestParam } from '../cache/models/request-param.model';
export class MetadataFieldDataService extends DataService<MetadataField> {
protected linkPath = 'metadatafields';
protected searchBySchemaLinkPath = 'bySchema';
protected searchByFieldNameLinkPath = 'byFieldName';
constructor(
protected requestService: RequestService,
@@ -53,6 +57,43 @@ export class MetadataFieldDataService extends DataService<MetadataField> {
return this.searchBy(this.searchBySchemaLinkPath, optionsWithSchema, ...linksToFollow);
}
/**
* Find metadata fields with either the partial metadata field name (e.g. "dc.ti") as query or an exact match to
* at least the schema, element or qualifier
* @param schema optional; an exact match of the prefix of the metadata schema (e.g. "dc", "dcterms", "eperson")
* @param element optional; an exact match of the field's element (e.g. "contributor", "title")
* @param qualifier optional; an exact match of the field's qualifier (e.g. "author", "alternative")
* @param query optional (if any of schema, element or qualifier used) - part of the fully qualified field,
* should start with the start of the schema, element or qualifier (e.g. “dc.ti”, “contributor”, “auth”, “contributor.ot”)
* @param exactName optional; the exact fully qualified field, should use the syntax schema.element.qualifier or
* schema.element if no qualifier exists (e.g. "dc.title", "dc.contributor.author"). It will only return one value
* if there's an exact match
* @param options The options info used to retrieve the fields
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved
*/
searchByFieldNameParams(schema: string, element: string, qualifier: string, query: string, exactName: string, options: FindListOptions = {}, ...linksToFollow: Array<FollowLinkConfig<MetadataField>>): Observable<RemoteData<PaginatedList<MetadataField>>> {
const optionParams = Object.assign(new FindListOptions(), options, {
searchParams: [
new RequestParam('schema', hasValue(schema) ? schema : ''),
new RequestParam('element', hasValue(element) ? element : ''),
new RequestParam('qualifier', hasValue(qualifier) ? qualifier : ''),
new RequestParam('query', hasValue(query) ? query : ''),
new RequestParam('exactName', hasValue(exactName) ? exactName : '')
]
});
return this.searchBy(this.searchByFieldNameLinkPath, optionParams, ...linksToFollow);
}
/**
* Finds a specific metadata field by name.
* @param exactFieldName The exact fully qualified field, should use the syntax schema.element.qualifier or
* schema.element if no qualifier exists (e.g. "dc.title", "dc.contributor.author"). It will only return one value
* if there's an exact match, empty list if there is no exact match.
*/
findByExactFieldName(exactFieldName: string): Observable<RemoteData<PaginatedList<MetadataField>>> {
return this.searchByFieldNameParams(null, null, null, null, exactFieldName, null);
}
/**
* Clear all metadata field requests
* Used for refreshing lists after adding/updating/removing a metadata field from a metadata schema

View File

@@ -9,7 +9,6 @@ import { ConfigResponseParsingService } from '../config/config-response-parsing.
import { AuthResponseParsingService } from '../auth/auth-response-parsing.service';
import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service';
import { SubmissionResponseParsingService } from '../submission/submission-response-parsing.service';
import { IntegrationResponseParsingService } from '../integration/integration-response-parsing.service';
import { RestRequestMethod } from './rest-request-method';
import { RequestParam } from '../cache/models/request-param.model';
import { EpersonResponseParsingService } from '../eperson/eperson-response-parsing.service';
@@ -20,12 +19,13 @@ import { ContentSourceResponseParsingService } from './content-source-response-p
import { MappedCollectionsReponseParsingService } from './mapped-collections-reponse-parsing.service';
import { ProcessFilesResponseParsingService } from './process-files-response-parsing.service';
import { TokenResponseParsingService } from '../auth/token-response-parsing.service';
import { VocabularyEntriesResponseParsingService } from '../submission/vocabularies/vocabulary-entries-response-parsing.service';
/* tslint:disable:max-classes-per-file */
// uuid and handle requests have separate endpoints
export enum IdentifierType {
UUID ='uuid',
UUID = 'uuid',
HANDLE = 'handle'
}
@@ -60,7 +60,7 @@ export class GetRequest extends RestRequest {
public href: string,
public body?: any,
public options?: HttpOptions
) {
) {
super(uuid, href, RestRequestMethod.GET, body, options)
}
}
@@ -71,7 +71,7 @@ export class PostRequest extends RestRequest {
public href: string,
public body?: any,
public options?: HttpOptions
) {
) {
super(uuid, href, RestRequestMethod.POST, body)
}
}
@@ -97,7 +97,7 @@ export class PutRequest extends RestRequest {
public href: string,
public body?: any,
public options?: HttpOptions
) {
) {
super(uuid, href, RestRequestMethod.PUT, body)
}
}
@@ -108,7 +108,7 @@ export class DeleteRequest extends RestRequest {
public href: string,
public body?: any,
public options?: HttpOptions
) {
) {
super(uuid, href, RestRequestMethod.DELETE, body)
}
}
@@ -119,7 +119,7 @@ export class OptionsRequest extends RestRequest {
public href: string,
public body?: any,
public options?: HttpOptions
) {
) {
super(uuid, href, RestRequestMethod.OPTIONS, body)
}
}
@@ -130,7 +130,7 @@ export class HeadRequest extends RestRequest {
public href: string,
public body?: any,
public options?: HttpOptions
) {
) {
super(uuid, href, RestRequestMethod.HEAD, body)
}
}
@@ -143,7 +143,7 @@ export class PatchRequest extends RestRequest {
public href: string,
public body?: any,
public options?: HttpOptions
) {
) {
super(uuid, href, RestRequestMethod.PATCH, body)
}
}
@@ -276,16 +276,6 @@ export class TokenPostRequest extends PostRequest {
}
}
export class IntegrationRequest extends GetRequest {
constructor(uuid: string, href: string) {
super(uuid, href);
}
getResponseParser(): GenericConstructor<ResponseParsingService> {
return IntegrationResponseParsingService;
}
}
/**
* Class representing a submission HTTP GET request object
*/
@@ -425,6 +415,15 @@ export class MyDSpaceRequest extends GetRequest {
public responseMsToLive = 10 * 1000;
}
/**
* Request to get vocabulary entries
*/
export class VocabularyEntriesRequest extends FindListRequest {
getResponseParser(): GenericConstructor<ResponseParsingService> {
return VocabularyEntriesResponseParsingService;
}
}
export class RequestError extends Error {
statusCode: number;
statusText: string;

View File

@@ -0,0 +1,32 @@
import { ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot, UrlTree } from '@angular/router';
import { Observable } from 'rxjs/internal/Observable';
import { returnEndUserAgreementUrlTreeOnFalse } from '../shared/operators';
/**
* An abstract guard for redirecting users to the user agreement page if a certain condition is met
* That condition is defined by abstract method hasAccepted
*/
export abstract class AbstractEndUserAgreementGuard implements CanActivate {
constructor(protected router: Router) {
}
/**
* True when the user agreement has been accepted
* The user will be redirected to the End User Agreement page if they haven't accepted it before
* A redirect URL will be provided with the navigation so the component can redirect the user back to the blocked route
* when they're finished accepting the agreement
*/
canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<boolean | UrlTree> {
return this.hasAccepted().pipe(
returnEndUserAgreementUrlTreeOnFalse(this.router, state.url)
);
}
/**
* This abstract method determines how the User Agreement has to be accepted before the user is allowed to visit
* the desired route
*/
abstract hasAccepted(): Observable<boolean>;
}

View File

@@ -0,0 +1,47 @@
import { EndUserAgreementService } from './end-user-agreement.service';
import { Router, UrlTree } from '@angular/router';
import { EndUserAgreementCookieGuard } from './end-user-agreement-cookie.guard';
describe('EndUserAgreementCookieGuard', () => {
let guard: EndUserAgreementCookieGuard;
let endUserAgreementService: EndUserAgreementService;
let router: Router;
beforeEach(() => {
endUserAgreementService = jasmine.createSpyObj('endUserAgreementService', {
isCookieAccepted: true
});
router = jasmine.createSpyObj('router', {
navigateByUrl: {},
parseUrl: new UrlTree(),
createUrlTree: new UrlTree()
});
guard = new EndUserAgreementCookieGuard(endUserAgreementService, router);
});
describe('canActivate', () => {
describe('when the cookie has been accepted', () => {
it('should return true', (done) => {
guard.canActivate(undefined, { url: Object.assign({ url: 'redirect' }) } as any).subscribe((result) => {
expect(result).toEqual(true);
done();
});
});
});
describe('when the cookie hasn\'t been accepted', () => {
beforeEach(() => {
(endUserAgreementService.isCookieAccepted as jasmine.Spy).and.returnValue(false);
});
it('should return a UrlTree', (done) => {
guard.canActivate(undefined, Object.assign({ url: 'redirect' })).subscribe((result) => {
expect(result).toEqual(jasmine.any(UrlTree));
done();
});
});
});
});
});

View File

@@ -0,0 +1,26 @@
import { Injectable } from '@angular/core';
import { AbstractEndUserAgreementGuard } from './abstract-end-user-agreement.guard';
import { Observable } from 'rxjs/internal/Observable';
import { of as observableOf } from 'rxjs';
import { EndUserAgreementService } from './end-user-agreement.service';
import { Router } from '@angular/router';
/**
* A guard redirecting users to the end agreement page when the user agreement cookie hasn't been accepted
*/
@Injectable()
export class EndUserAgreementCookieGuard extends AbstractEndUserAgreementGuard {
constructor(protected endUserAgreementService: EndUserAgreementService,
protected router: Router) {
super(router);
}
/**
* True when the user agreement cookie has been accepted
*/
hasAccepted(): Observable<boolean> {
return observableOf(this.endUserAgreementService.isCookieAccepted());
}
}

View File

@@ -0,0 +1,49 @@
import { EndUserAgreementCurrentUserGuard } from './end-user-agreement-current-user.guard';
import { EndUserAgreementService } from './end-user-agreement.service';
import { Router, UrlTree } from '@angular/router';
import { of as observableOf } from 'rxjs';
import { AuthService } from '../auth/auth.service';
describe('EndUserAgreementGuard', () => {
let guard: EndUserAgreementCurrentUserGuard;
let endUserAgreementService: EndUserAgreementService;
let router: Router;
beforeEach(() => {
endUserAgreementService = jasmine.createSpyObj('endUserAgreementService', {
hasCurrentUserAcceptedAgreement: observableOf(true)
});
router = jasmine.createSpyObj('router', {
navigateByUrl: {},
parseUrl: new UrlTree(),
createUrlTree: new UrlTree()
});
guard = new EndUserAgreementCurrentUserGuard(endUserAgreementService, router);
});
describe('canActivate', () => {
describe('when the user has accepted the agreement', () => {
it('should return true', (done) => {
guard.canActivate(undefined, Object.assign({ url: 'redirect' })).subscribe((result) => {
expect(result).toEqual(true);
done();
});
});
});
describe('when the user hasn\'t accepted the agreement', () => {
beforeEach(() => {
(endUserAgreementService.hasCurrentUserAcceptedAgreement as jasmine.Spy).and.returnValue(observableOf(false));
});
it('should return a UrlTree', (done) => {
guard.canActivate(undefined, Object.assign({ url: 'redirect' })).subscribe((result) => {
expect(result).toEqual(jasmine.any(UrlTree));
done();
});
});
});
});
});

View File

@@ -0,0 +1,25 @@
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs/internal/Observable';
import { AbstractEndUserAgreementGuard } from './abstract-end-user-agreement.guard';
import { EndUserAgreementService } from './end-user-agreement.service';
import { Router } from '@angular/router';
/**
* A guard redirecting logged in users to the end agreement page when they haven't accepted the latest user agreement
*/
@Injectable()
export class EndUserAgreementCurrentUserGuard extends AbstractEndUserAgreementGuard {
constructor(protected endUserAgreementService: EndUserAgreementService,
protected router: Router) {
super(router);
}
/**
* True when the currently logged in user has accepted the agreements or when the user is not currently authenticated
*/
hasAccepted(): Observable<boolean> {
return this.endUserAgreementService.hasCurrentUserAcceptedAgreement(true);
}
}

View File

@@ -0,0 +1,138 @@
import {
END_USER_AGREEMENT_COOKIE,
END_USER_AGREEMENT_METADATA_FIELD,
EndUserAgreementService
} from './end-user-agreement.service';
import { CookieServiceMock } from '../../shared/mocks/cookie.service.mock';
import { of as observableOf } from 'rxjs';
import { EPerson } from '../eperson/models/eperson.model';
import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
import { RestResponse } from '../cache/response.models';
describe('EndUserAgreementService', () => {
let service: EndUserAgreementService;
let userWithMetadata: EPerson;
let userWithoutMetadata: EPerson;
let cookie;
let authService;
let ePersonService;
beforeEach(() => {
userWithMetadata = Object.assign(new EPerson(), {
metadata: {
[END_USER_AGREEMENT_METADATA_FIELD]: [
{
value: 'true'
}
]
}
});
userWithoutMetadata = Object.assign(new EPerson());
cookie = new CookieServiceMock();
authService = jasmine.createSpyObj('authService', {
isAuthenticated: observableOf(true),
getAuthenticatedUserFromStore: observableOf(userWithMetadata)
});
ePersonService = jasmine.createSpyObj('ePersonService', {
update: createSuccessfulRemoteDataObject$(userWithMetadata),
patch: observableOf(new RestResponse(true, 200, 'OK'))
});
service = new EndUserAgreementService(cookie, authService, ePersonService);
});
describe('when the cookie is set to true', () => {
beforeEach(() => {
cookie.set(END_USER_AGREEMENT_COOKIE, true);
});
it('hasCurrentUserOrCookieAcceptedAgreement should return true', (done) => {
service.hasCurrentUserOrCookieAcceptedAgreement(false).subscribe((result) => {
expect(result).toEqual(true);
done();
});
});
it('isCookieAccepted should return true', () => {
expect(service.isCookieAccepted()).toEqual(true);
});
it('removeCookieAccepted should remove the cookie', () => {
service.removeCookieAccepted();
expect(cookie.get(END_USER_AGREEMENT_COOKIE)).toBeUndefined();
});
});
describe('when the cookie isn\'t set', () => {
describe('and the user is authenticated', () => {
beforeEach(() => {
(authService.isAuthenticated as jasmine.Spy).and.returnValue(observableOf(true));
});
describe('and the user contains agreement metadata', () => {
beforeEach(() => {
(authService.getAuthenticatedUserFromStore as jasmine.Spy).and.returnValue(observableOf(userWithMetadata));
});
it('hasCurrentUserOrCookieAcceptedAgreement should return true', (done) => {
service.hasCurrentUserOrCookieAcceptedAgreement(false).subscribe((result) => {
expect(result).toEqual(true);
done();
});
});
});
describe('and the user doesn\'t contain agreement metadata', () => {
beforeEach(() => {
(authService.getAuthenticatedUserFromStore as jasmine.Spy).and.returnValue(observableOf(userWithoutMetadata));
});
it('hasCurrentUserOrCookieAcceptedAgreement should return false', (done) => {
service.hasCurrentUserOrCookieAcceptedAgreement(false).subscribe((result) => {
expect(result).toEqual(false);
done();
});
});
});
it('setUserAcceptedAgreement should update the user with new metadata', (done) => {
service.setUserAcceptedAgreement(true).subscribe(() => {
expect(ePersonService.patch).toHaveBeenCalled();
done();
});
});
});
describe('and the user is not authenticated', () => {
beforeEach(() => {
(authService.isAuthenticated as jasmine.Spy).and.returnValue(observableOf(false));
});
it('hasCurrentUserOrCookieAcceptedAgreement should return false', (done) => {
service.hasCurrentUserOrCookieAcceptedAgreement(false).subscribe((result) => {
expect(result).toEqual(false);
done();
});
});
it('setUserAcceptedAgreement should set the cookie to true', (done) => {
service.setUserAcceptedAgreement(true).subscribe(() => {
expect(cookie.get(END_USER_AGREEMENT_COOKIE)).toEqual(true);
done();
});
});
});
it('isCookieAccepted should return false', () => {
expect(service.isCookieAccepted()).toEqual(false);
});
it('setCookieAccepted should set the cookie', () => {
service.setCookieAccepted(true);
expect(cookie.get(END_USER_AGREEMENT_COOKIE)).toEqual(true);
});
});
});

View File

@@ -0,0 +1,111 @@
import { Injectable } from '@angular/core';
import { AuthService } from '../auth/auth.service';
import { CookieService } from '../services/cookie.service';
import { Observable } from 'rxjs/internal/Observable';
import { of as observableOf } from 'rxjs';
import { map, switchMap, take } from 'rxjs/operators';
import { hasValue } from '../../shared/empty.util';
import { EPersonDataService } from '../eperson/eperson-data.service';
export const END_USER_AGREEMENT_COOKIE = 'hasAgreedEndUser';
export const END_USER_AGREEMENT_METADATA_FIELD = 'dspace.agreements.end-user';
/**
* Service for checking and managing the status of the current end user agreement
*/
@Injectable()
export class EndUserAgreementService {
constructor(protected cookie: CookieService,
protected authService: AuthService,
protected ePersonService: EPersonDataService) {
}
/**
* Whether or not either the cookie was accepted or the current user has accepted the End User Agreement
* @param acceptedWhenAnonymous Whether or not the user agreement should be considered accepted if the user is
* currently not authenticated (anonymous)
*/
hasCurrentUserOrCookieAcceptedAgreement(acceptedWhenAnonymous: boolean): Observable<boolean> {
if (this.isCookieAccepted()) {
return observableOf(true);
} else {
return this.hasCurrentUserAcceptedAgreement(acceptedWhenAnonymous);
}
}
/**
* Whether or not the current user has accepted the End User Agreement
* @param acceptedWhenAnonymous Whether or not the user agreement should be considered accepted if the user is
* currently not authenticated (anonymous)
*/
hasCurrentUserAcceptedAgreement(acceptedWhenAnonymous: boolean): Observable<boolean> {
return this.authService.isAuthenticated().pipe(
switchMap((authenticated) => {
if (authenticated) {
return this.authService.getAuthenticatedUserFromStore().pipe(
map((user) => hasValue(user) && user.hasMetadata(END_USER_AGREEMENT_METADATA_FIELD) && user.firstMetadata(END_USER_AGREEMENT_METADATA_FIELD).value === 'true')
);
} else {
return observableOf(acceptedWhenAnonymous);
}
})
);
}
/**
* Set the current user's accepted agreement status
* When a user is authenticated, set his/her metadata to the provided value
* When no user is authenticated, set the cookie to the provided value
* @param accepted
*/
setUserAcceptedAgreement(accepted: boolean): Observable<boolean> {
return this.authService.isAuthenticated().pipe(
switchMap((authenticated) => {
if (authenticated) {
return this.authService.getAuthenticatedUserFromStore().pipe(
take(1),
switchMap((user) => {
const newValue = { value: String(accepted) };
let operation;
if (user.hasMetadata(END_USER_AGREEMENT_METADATA_FIELD)) {
operation = { op: 'replace', path: `/metadata/${END_USER_AGREEMENT_METADATA_FIELD}/0`, value: newValue };
} else {
operation = { op: 'add', path: `/metadata/${END_USER_AGREEMENT_METADATA_FIELD}`, value: [ newValue ] };
}
return this.ePersonService.patch(user, [operation]);
}),
map((response) => response.isSuccessful)
);
} else {
this.setCookieAccepted(accepted);
return observableOf(true);
}
}),
take(1)
);
}
/**
* Is the End User Agreement accepted in the cookie?
*/
isCookieAccepted(): boolean {
return this.cookie.get(END_USER_AGREEMENT_COOKIE) === true;
}
/**
* Set the cookie's End User Agreement accepted state
* @param accepted
*/
setCookieAccepted(accepted: boolean) {
this.cookie.set(END_USER_AGREEMENT_COOKIE, accepted);
}
/**
* Remove the End User Agreement cookie
*/
removeCookieAccepted() {
this.cookie.remove(END_USER_AGREEMENT_COOKIE);
}
}

View File

@@ -3,7 +3,7 @@ import { Injectable } from '@angular/core';
import { createSelector, select, Store } from '@ngrx/store';
import { Observable } from 'rxjs';
import { distinctUntilChanged, filter, map, switchMap, take, tap } from 'rxjs/operators';
import { filter, map, take, tap } from 'rxjs/operators';
import {
GroupRegistryCancelGroupAction,
GroupRegistryEditGroupAction
@@ -21,18 +21,12 @@ import { DataService } from '../data/data.service';
import { DSOChangeAnalyzer } from '../data/dso-change-analyzer.service';
import { PaginatedList } from '../data/paginated-list';
import { RemoteData } from '../data/remote-data';
import {
CreateRequest,
DeleteRequest,
FindListOptions,
FindListRequest,
PostRequest
} from '../data/request.models';
import { CreateRequest, DeleteRequest, FindListOptions, FindListRequest, PostRequest } from '../data/request.models';
import { RequestService } from '../data/request.service';
import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service';
import { HALEndpointService } from '../shared/hal-endpoint.service';
import { configureRequest, getResponseFromEntry} from '../shared/operators';
import { getResponseFromEntry } from '../shared/operators';
import { EPerson } from './models/eperson.model';
import { Group } from './models/group.model';
import { dataService } from '../cache/builders/build-decorators';

View File

@@ -1,21 +0,0 @@
import { Injectable } from '@angular/core';
import { RequestService } from '../data/request.service';
import { IntegrationService } from './integration.service';
import { HALEndpointService } from '../shared/hal-endpoint.service';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
@Injectable()
export class AuthorityService extends IntegrationService {
protected linkPath = 'authorities';
protected entriesEndpoint = 'entries';
protected entryValueEndpoint = 'entryValues';
constructor(
protected requestService: RequestService,
protected rdbService: RemoteDataBuildService,
protected halService: HALEndpointService) {
super();
}
}

View File

@@ -1,12 +0,0 @@
import { PageInfo } from '../shared/page-info.model';
import { IntegrationModel } from './models/integration.model';
/**
* A class to represent the data retrieved by an Integration service
*/
export class IntegrationData {
constructor(
public pageInfo: PageInfo,
public payload: IntegrationModel[]
) { }
}

View File

@@ -1,221 +0,0 @@
import { Store } from '@ngrx/store';
import { ObjectCacheService } from '../cache/object-cache.service';
import { ErrorResponse, IntegrationSuccessResponse } from '../cache/response.models';
import { CoreState } from '../core.reducers';
import { PaginatedList } from '../data/paginated-list';
import { IntegrationRequest } from '../data/request.models';
import { PageInfo } from '../shared/page-info.model';
import { IntegrationResponseParsingService } from './integration-response-parsing.service';
import { AuthorityValue } from './models/authority.value';
describe('IntegrationResponseParsingService', () => {
let service: IntegrationResponseParsingService;
const store = {} as Store<CoreState>;
const objectCacheService = new ObjectCacheService(store, undefined);
const name = 'type';
const metadata = 'dc.type';
const query = '';
const uuid = 'd9d30c0c-69b7-4369-8397-ca67c888974d';
const integrationEndpoint = 'https://rest.api/integration/authorities';
const entriesEndpoint = `${integrationEndpoint}/${name}/entries?query=${query}&metadata=${metadata}&uuid=${uuid}`;
let validRequest;
let validResponse;
let invalidResponse1;
let invalidResponse2;
let pageInfo;
let definitions;
function initVars() {
pageInfo = Object.assign(new PageInfo(), {
elementsPerPage: 5,
totalElements: 5,
totalPages: 1,
currentPage: 1,
_links: {
self: { href: 'https://rest.api/integration/authorities/type/entries' }
}
});
definitions = new PaginatedList(pageInfo, [
Object.assign(new AuthorityValue(), {
type: 'authority',
display: 'One',
id: 'One',
otherInformation: undefined,
value: 'One'
}),
Object.assign(new AuthorityValue(), {
type: 'authority',
display: 'Two',
id: 'Two',
otherInformation: undefined,
value: 'Two'
}),
Object.assign(new AuthorityValue(), {
type: 'authority',
display: 'Three',
id: 'Three',
otherInformation: undefined,
value: 'Three'
}),
Object.assign(new AuthorityValue(), {
type: 'authority',
display: 'Four',
id: 'Four',
otherInformation: undefined,
value: 'Four'
}),
Object.assign(new AuthorityValue(), {
type: 'authority',
display: 'Five',
id: 'Five',
otherInformation: undefined,
value: 'Five'
})
]);
validRequest = new IntegrationRequest('69f375b5-19f4-4453-8c7a-7dc5c55aafbb', entriesEndpoint);
validResponse = {
payload: {
page: {
number: 0,
size: 5,
totalElements: 5,
totalPages: 1
},
_embedded: {
authorityEntries: [
{
display: 'One',
id: 'One',
otherInformation: {},
type: 'authority',
value: 'One'
},
{
display: 'Two',
id: 'Two',
otherInformation: {},
type: 'authority',
value: 'Two'
},
{
display: 'Three',
id: 'Three',
otherInformation: {},
type: 'authority',
value: 'Three'
},
{
display: 'Four',
id: 'Four',
otherInformation: {},
type: 'authority',
value: 'Four'
},
{
display: 'Five',
id: 'Five',
otherInformation: {},
type: 'authority',
value: 'Five'
},
],
},
_links: {
self: { href: 'https://rest.api/integration/authorities/type/entries' }
}
},
statusCode: 200,
statusText: 'OK'
};
invalidResponse1 = {
payload: {},
statusCode: 400,
statusText: 'Bad Request'
};
invalidResponse2 = {
payload: {
page: {
number: 0,
size: 5,
totalElements: 5,
totalPages: 1
},
_embedded: {
authorityEntries: [
{
display: 'One',
id: 'One',
otherInformation: {},
type: 'authority',
value: 'One'
},
{
display: 'Two',
id: 'Two',
otherInformation: {},
type: 'authority',
value: 'Two'
},
{
display: 'Three',
id: 'Three',
otherInformation: {},
type: 'authority',
value: 'Three'
},
{
display: 'Four',
id: 'Four',
otherInformation: {},
type: 'authority',
value: 'Four'
},
{
display: 'Five',
id: 'Five',
otherInformation: {},
type: 'authority',
value: 'Five'
},
],
},
_links: {}
},
statusCode: 500,
statusText: 'Internal Server Error'
};
}
beforeEach(() => {
initVars();
service = new IntegrationResponseParsingService(objectCacheService);
});
describe('parse', () => {
it('should return a IntegrationSuccessResponse if data contains a valid endpoint response', () => {
const response = service.parse(validRequest, validResponse);
expect(response.constructor).toBe(IntegrationSuccessResponse);
});
it('should return an ErrorResponse if data contains an invalid config endpoint response', () => {
const response1 = service.parse(validRequest, invalidResponse1);
const response2 = service.parse(validRequest, invalidResponse2);
expect(response1.constructor).toBe(ErrorResponse);
expect(response2.constructor).toBe(ErrorResponse);
});
it('should return a IntegrationSuccessResponse with data definition', () => {
const response = service.parse(validRequest, validResponse);
expect((response as any).dataDefinition).toEqual(definitions);
});
});
});

View File

@@ -1,50 +0,0 @@
import { Inject, Injectable } from '@angular/core';
import { RestRequest } from '../data/request.models';
import { ResponseParsingService } from '../data/parsing.service';
import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model';
import { ErrorResponse, IntegrationSuccessResponse, RestResponse } from '../cache/response.models';
import { isNotEmpty } from '../../shared/empty.util';
import { BaseResponseParsingService } from '../data/base-response-parsing.service';
import { ObjectCacheService } from '../cache/object-cache.service';
import { IntegrationModel } from './models/integration.model';
import { AuthorityValue } from './models/authority.value';
import { PaginatedList } from '../data/paginated-list';
@Injectable()
export class IntegrationResponseParsingService extends BaseResponseParsingService implements ResponseParsingService {
protected toCache = true;
constructor(
protected objectCache: ObjectCacheService,
) {
super();
}
parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse {
if (isNotEmpty(data.payload) && isNotEmpty(data.payload._links)) {
const dataDefinition = this.process<IntegrationModel>(data.payload, request);
return new IntegrationSuccessResponse(this.processResponse(dataDefinition), data.statusCode, data.statusText, this.processPageInfo(data.payload));
} else {
return new ErrorResponse(
Object.assign(
new Error('Unexpected response from Integration endpoint'),
{statusCode: data.statusCode, statusText: data.statusText}
)
);
}
}
protected processResponse(data: PaginatedList<IntegrationModel>): any {
const returnList = Array.of();
data.page.forEach((item, index) => {
if (item.type === AuthorityValue.type.value) {
data.page[index] = Object.assign(new AuthorityValue(), item);
}
});
return data;
}
}

View File

@@ -1,96 +0,0 @@
import { cold, getTestScheduler } from 'jasmine-marbles';
import { TestScheduler } from 'rxjs/testing';
import { getMockRequestService } from '../../shared/mocks/request.service.mock';
import { RequestService } from '../data/request.service';
import { IntegrationRequest } from '../data/request.models';
import { HALEndpointService } from '../shared/hal-endpoint.service';
import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub';
import { IntegrationService } from './integration.service';
import { IntegrationSearchOptions } from './models/integration-options.model';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
import { getMockRemoteDataBuildService } from '../../shared/mocks/remote-data-build.service.mock';
const LINK_NAME = 'authorities';
const ENTRIES = 'entries';
const ENTRY_VALUE = 'entryValue';
class TestService extends IntegrationService {
protected linkPath = LINK_NAME;
protected entriesEndpoint = ENTRIES;
protected entryValueEndpoint = ENTRY_VALUE;
constructor(
protected requestService: RequestService,
protected rdbService: RemoteDataBuildService,
protected halService: HALEndpointService) {
super();
}
}
describe('IntegrationService', () => {
let scheduler: TestScheduler;
let service: TestService;
let requestService: RequestService;
let rdbService: RemoteDataBuildService;
let halService: any;
let findOptions: IntegrationSearchOptions;
const name = 'type';
const metadata = 'dc.type';
const query = '';
const value = 'test';
const uuid = 'd9d30c0c-69b7-4369-8397-ca67c888974d';
const integrationEndpoint = 'https://rest.api/integration';
const serviceEndpoint = `${integrationEndpoint}/${LINK_NAME}`;
const entriesEndpoint = `${serviceEndpoint}/${name}/entries?query=${query}&metadata=${metadata}&uuid=${uuid}`;
const entryValueEndpoint = `${serviceEndpoint}/${name}/entryValue/${value}?metadata=${metadata}`;
findOptions = new IntegrationSearchOptions(uuid, name, metadata);
function initTestService(): TestService {
return new TestService(
requestService,
rdbService,
halService
);
}
beforeEach(() => {
requestService = getMockRequestService();
rdbService = getMockRemoteDataBuildService();
scheduler = getTestScheduler();
halService = new HALEndpointServiceStub(integrationEndpoint);
findOptions = new IntegrationSearchOptions(uuid, name, metadata, query);
service = initTestService();
});
describe('getEntriesByName', () => {
it('should configure a new IntegrationRequest', () => {
const expected = new IntegrationRequest(requestService.generateRequestId(), entriesEndpoint);
scheduler.schedule(() => service.getEntriesByName(findOptions).subscribe());
scheduler.flush();
expect(requestService.configure).toHaveBeenCalledWith(expected);
});
});
describe('getEntryByValue', () => {
it('should configure a new IntegrationRequest', () => {
findOptions = new IntegrationSearchOptions(
null,
name,
metadata,
value);
const expected = new IntegrationRequest(requestService.generateRequestId(), entryValueEndpoint);
scheduler.schedule(() => service.getEntryByValue(findOptions).subscribe());
scheduler.flush();
expect(requestService.configure).toHaveBeenCalledWith(expected);
});
});
});

View File

@@ -1,121 +0,0 @@
import { Observable, of as observableOf, throwError as observableThrowError } from 'rxjs';
import { distinctUntilChanged, filter, map, mergeMap, tap } from 'rxjs/operators';
import { RequestService } from '../data/request.service';
import { IntegrationSuccessResponse } from '../cache/response.models';
import { GetRequest, IntegrationRequest } from '../data/request.models';
import { hasValue, isNotEmpty } from '../../shared/empty.util';
import { HALEndpointService } from '../shared/hal-endpoint.service';
import { IntegrationData } from './integration-data';
import { IntegrationSearchOptions } from './models/integration-options.model';
import { getResponseFromEntry } from '../shared/operators';
export abstract class IntegrationService {
protected request: IntegrationRequest;
protected abstract requestService: RequestService;
protected abstract linkPath: string;
protected abstract entriesEndpoint: string;
protected abstract entryValueEndpoint: string;
protected abstract halService: HALEndpointService;
protected getData(request: GetRequest): Observable<IntegrationData> {
return this.requestService.getByHref(request.href).pipe(
getResponseFromEntry(),
mergeMap((response: IntegrationSuccessResponse) => {
if (response.isSuccessful && isNotEmpty(response)) {
return observableOf(new IntegrationData(
response.pageInfo,
(response.dataDefinition) ? response.dataDefinition.page : []
));
} else if (!response.isSuccessful) {
return observableThrowError(new Error(`Couldn't retrieve the integration data`));
}
}),
distinctUntilChanged()
);
}
protected getEntriesHref(endpoint, options: IntegrationSearchOptions = new IntegrationSearchOptions()): string {
let result;
const args = [];
if (hasValue(options.name)) {
result = `${endpoint}/${options.name}/${this.entriesEndpoint}`;
} else {
result = endpoint;
}
if (hasValue(options.query)) {
args.push(`query=${options.query}`);
}
if (hasValue(options.metadata)) {
args.push(`metadata=${options.metadata}`);
}
if (hasValue(options.uuid)) {
args.push(`uuid=${options.uuid}`);
}
if (hasValue(options.currentPage) && typeof options.currentPage === 'number') {
/* TODO: this is a temporary fix for the pagination start index (0 or 1) discrepancy between the rest and the frontend respectively */
args.push(`page=${options.currentPage - 1}`);
}
if (hasValue(options.elementsPerPage)) {
args.push(`size=${options.elementsPerPage}`);
}
if (hasValue(options.sort)) {
args.push(`sort=${options.sort.field},${options.sort.direction}`);
}
if (isNotEmpty(args)) {
result = `${result}?${args.join('&')}`;
}
return result;
}
protected getEntryValueHref(endpoint, options: IntegrationSearchOptions = new IntegrationSearchOptions()): string {
let result;
const args = [];
if (hasValue(options.name) && hasValue(options.query)) {
result = `${endpoint}/${options.name}/${this.entryValueEndpoint}/${options.query}`;
} else {
result = endpoint;
}
if (hasValue(options.metadata)) {
args.push(`metadata=${options.metadata}`);
}
if (isNotEmpty(args)) {
result = `${result}?${args.join('&')}`;
}
return result;
}
public getEntriesByName(options: IntegrationSearchOptions): Observable<IntegrationData> {
return this.halService.getEndpoint(this.linkPath).pipe(
map((endpoint: string) => this.getEntriesHref(endpoint, options)),
filter((href: string) => isNotEmpty(href)),
distinctUntilChanged(),
map((endpointURL: string) => new IntegrationRequest(this.requestService.generateRequestId(), endpointURL)),
tap((request: GetRequest) => this.requestService.configure(request)),
mergeMap((request: GetRequest) => this.getData(request)),
distinctUntilChanged());
}
public getEntryByValue(options: IntegrationSearchOptions): Observable<IntegrationData> {
return this.halService.getEndpoint(this.linkPath).pipe(
map((endpoint: string) => this.getEntryValueHref(endpoint, options)),
filter((href: string) => isNotEmpty(href)),
distinctUntilChanged(),
map((endpointURL: string) => new IntegrationRequest(this.requestService.generateRequestId(), endpointURL)),
tap((request: GetRequest) => this.requestService.configure(request)),
mergeMap((request: GetRequest) => this.getData(request)),
distinctUntilChanged());
}
}

View File

@@ -1,16 +0,0 @@
export class AuthorityOptions {
name: string;
metadata: string;
scope: string;
closed: boolean;
constructor(name: string,
metadata: string,
scope: string,
closed: boolean = false) {
this.name = name;
this.metadata = metadata;
this.scope = scope;
this.closed = closed;
}
}

View File

@@ -1,10 +0,0 @@
import { ResourceType } from '../../shared/resource-type';
/**
* The resource type for AuthorityValue
*
* Needs to be in a separate file to prevent circular
* dependencies in webpack.
*/
export const AUTHORITY_VALUE = new ResourceType('authority');

View File

@@ -1,92 +0,0 @@
import { autoserialize, deserialize, inheritSerialization } from 'cerialize';
import { isNotEmpty } from '../../../shared/empty.util';
import { OtherInformation } from '../../../shared/form/builder/models/form-field-metadata-value.model';
import { typedObject } from '../../cache/builders/build-decorators';
import { HALLink } from '../../shared/hal-link.model';
import { MetadataValueInterface } from '../../shared/metadata.models';
import { AUTHORITY_VALUE } from './authority.resource-type';
import { IntegrationModel } from './integration.model';
import { PLACEHOLDER_PARENT_METADATA } from '../../../shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-constants';
/**
* Class representing an authority object
*/
@typedObject
@inheritSerialization(IntegrationModel)
export class AuthorityValue extends IntegrationModel implements MetadataValueInterface {
static type = AUTHORITY_VALUE;
/**
* The identifier of this authority
*/
@autoserialize
id: string;
/**
* The display value of this authority
*/
@autoserialize
display: string;
/**
* The value of this authority
*/
@autoserialize
value: string;
/**
* An object containing additional information related to this authority
*/
@autoserialize
otherInformation: OtherInformation;
/**
* The language code of this authority value
*/
@autoserialize
language: string;
/**
* The {@link HALLink}s for this AuthorityValue
*/
@deserialize
_links: {
self: HALLink,
};
/**
* This method checks if authority has an identifier value
*
* @return boolean
*/
hasAuthority(): boolean {
return isNotEmpty(this.id);
}
/**
* This method checks if authority has a value
*
* @return boolean
*/
hasValue(): boolean {
return isNotEmpty(this.value);
}
/**
* This method checks if authority has related information object
*
* @return boolean
*/
hasOtherInformation(): boolean {
return isNotEmpty(this.otherInformation);
}
/**
* This method checks if authority has a placeholder as value
*
* @return boolean
*/
hasPlaceholder(): boolean {
return this.hasValue() && this.value === PLACEHOLDER_PARENT_METADATA;
}
}

View File

@@ -1,14 +0,0 @@
import { SortOptions } from '../../cache/models/sort-options.model';
export class IntegrationSearchOptions {
constructor(public uuid: string = '',
public name: string = '',
public metadata: string = '',
public query: string = '',
public elementsPerPage?: number,
public currentPage?: number,
public sort?: SortOptions) {
}
}

View File

@@ -1,22 +0,0 @@
import { autoserialize, deserialize } from 'cerialize';
import { CacheableObject } from '../../cache/object-cache.reducer';
import { HALLink } from '../../shared/hal-link.model';
export abstract class IntegrationModel implements CacheableObject {
@autoserialize
self: string;
@autoserialize
uuid: string;
@autoserialize
public type: any;
@deserialize
public _links: {
self: HALLink,
[name: string]: HALLink
}
}

View File

@@ -1,11 +1,16 @@
import { Store } from '@ngrx/store';
import { CoreState } from '../../core.reducers';
import { NewPatchAddOperationAction, NewPatchMoveOperationAction, NewPatchRemoveOperationAction, NewPatchReplaceOperationAction } from '../json-patch-operations.actions';
import {
NewPatchAddOperationAction,
NewPatchMoveOperationAction,
NewPatchRemoveOperationAction,
NewPatchReplaceOperationAction
} from '../json-patch-operations.actions';
import { JsonPatchOperationPathObject } from './json-patch-operation-path-combiner';
import { Injectable } from '@angular/core';
import { hasNoValue, isEmpty, isNotEmpty } from '../../../shared/empty.util';
import { hasNoValue, hasValue, isEmpty, isNotEmpty } from '../../../shared/empty.util';
import { dateToISOFormat } from '../../../shared/date.util';
import { AuthorityValue } from '../../integration/models/authority.value';
import { VocabularyEntry } from '../../submission/vocabularies/models/vocabulary-entry.model';
import { FormFieldMetadataValueObject } from '../../../shared/form/builder/models/form-field-metadata-value.model';
import { FormFieldLanguageValueObject } from '../../../shared/form/builder/models/form-field-language-value.model';
@@ -96,7 +101,7 @@ export class JsonPatchOperationsBuilder {
protected prepareValue(value: any, plain: boolean, first: boolean) {
let operationValue: any = null;
if (isNotEmpty(value)) {
if (hasValue(value)) {
if (plain) {
operationValue = value;
} else {
@@ -125,10 +130,12 @@ export class JsonPatchOperationsBuilder {
operationValue = value;
} else if (value instanceof Date) {
operationValue = new FormFieldMetadataValueObject(dateToISOFormat(value));
} else if (value instanceof AuthorityValue) {
} else if (value instanceof VocabularyEntry) {
operationValue = this.prepareAuthorityValue(value);
} else if (value instanceof FormFieldLanguageValueObject) {
operationValue = new FormFieldMetadataValueObject(value.value, value.language);
} else if (value.hasOwnProperty('authority')) {
operationValue = new FormFieldMetadataValueObject(value.value, value.language, value.authority);
} else if (value.hasOwnProperty('value')) {
operationValue = new FormFieldMetadataValueObject(value.value);
} else {
@@ -144,10 +151,10 @@ export class JsonPatchOperationsBuilder {
return operationValue;
}
protected prepareAuthorityValue(value: any) {
let operationValue: any = null;
if (isNotEmpty(value.id)) {
operationValue = new FormFieldMetadataValueObject(value.value, value.language, value.id);
protected prepareAuthorityValue(value: any): FormFieldMetadataValueObject {
let operationValue: FormFieldMetadataValueObject;
if (isNotEmpty(value.authority)) {
operationValue = new FormFieldMetadataValueObject(value.value, value.language, value.authority);
} else {
operationValue = new FormFieldMetadataValueObject(value.value, value.language);
}

View File

@@ -1,9 +1,8 @@
import { async, TestBed } from '@angular/core/testing';
import { getTestScheduler } from 'jasmine-marbles';
import { TestScheduler } from 'rxjs/testing';
import { of as observableOf } from 'rxjs';
import { Store, StoreModule } from '@ngrx/store';
import { catchError } from 'rxjs/operators';
import { Store } from '@ngrx/store';
import { getMockRequestService } from '../../shared/mocks/request.service.mock';
import { RequestService } from '../data/request.service';
@@ -22,7 +21,6 @@ import {
StartTransactionPatchOperationsAction
} from './json-patch-operations.actions';
import { RequestEntry } from '../data/request.reducer';
import { catchError } from 'rxjs/operators';
class TestService extends JsonPatchOperationsService<SubmitDataResponseDefinitionObject, SubmissionPatchRequest> {
protected linkPath = '';

View File

@@ -10,7 +10,7 @@ import { Observable, of as observableOf, combineLatest } from 'rxjs';
import { map, take, flatMap } from 'rxjs/operators';
import { NativeWindowService, NativeWindowRef } from '../services/window.service';
export const LANG_COOKIE = 'language_cookie';
export const LANG_COOKIE = 'dsLanguage';
/**
* This enum defines the possible origin of the languages

View File

@@ -68,8 +68,8 @@ export class MetadataField extends ListableObject implements HALResource {
schema?: Observable<RemoteData<MetadataSchema>>;
/**
* Method to print this metadata field as a string
* @param separator The separator between the schema, element and qualifier in the string
* Method to print this metadata field as a string without the schema
* @param separator The separator between element and qualifier in the string
*/
toString(separator: string = '.'): string {
let key = this.element;

View File

@@ -30,7 +30,6 @@ import { MetadataSchemaDataService } from '../data/metadata-schema-data.service'
import { MetadataFieldDataService } from '../data/metadata-field-data.service';
import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model';
import { RequestParam } from '../cache/models/request-param.model';
import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
const metadataRegistryStateSelector = (state: AppState) => state.metadataRegistry;
const editMetadataSchemaSelector = createSelector(metadataRegistryStateSelector, (metadataState: MetadataRegistryState) => metadataState.editSchema);
@@ -90,20 +89,6 @@ export class RegistryService {
return this.metadataFieldService.findBySchema(schema, options, ...linksToFollow);
}
/**
* Retrieve all existing metadata fields as a paginated list
* @param options Options to determine which page of metadata fields should be requested
* When no options are provided, all metadata fields are requested in one large page
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved
* @returns an observable that emits a remote data object with a page of metadata fields
*/
// TODO this is temporarily disabled. The performance is too bad.
// It is used down the line for validation. That validation will have to be rewritten against a new rest endpoint.
// Not by downloading the list of all fields.
public getAllMetadataFields(options?: FindListOptions, ...linksToFollow: Array<FollowLinkConfig<MetadataField>>): Observable<RemoteData<PaginatedList<MetadataField>>> {
return createSuccessfulRemoteDataObject$(new PaginatedList<MetadataField>(null, []));
}
public editMetadataSchema(schema: MetadataSchema) {
this.store.dispatch(new MetadataRegistryEditSchemaAction(schema));
}
@@ -151,6 +136,7 @@ export class RegistryService {
public getSelectedMetadataSchemas(): Observable<MetadataSchema[]> {
return this.store.pipe(select(selectedMetadataSchemasSelector));
}
/**
* Method to start editing a metadata field, dispatches an edit field action
* @param field The field that's being edited
@@ -165,12 +151,14 @@ export class RegistryService {
public cancelEditMetadataField() {
this.store.dispatch(new MetadataRegistryCancelFieldAction());
}
/**
* Method to retrieve the metadata field that are currently being edited
*/
public getActiveMetadataField(): Observable<MetadataField> {
return this.store.pipe(select(editMetadataFieldSelector));
}
/**
* Method to select a metadata field, dispatches a select field action
* @param field The field that's being selected
@@ -178,6 +166,7 @@ export class RegistryService {
public selectMetadataField(field: MetadataField) {
this.store.dispatch(new MetadataRegistrySelectFieldAction(field));
}
/**
* Method to deselect a metadata field, dispatches a deselect field action
* @param field The field that's it being deselected
@@ -185,6 +174,7 @@ export class RegistryService {
public deselectMetadataField(field: MetadataField) {
this.store.dispatch(new MetadataRegistryDeselectFieldAction(field));
}
/**
* Method to deselect all currently selected metadata fields, dispatches a deselect all field action
*/
@@ -213,7 +203,7 @@ export class RegistryService {
getFirstSucceededRemoteDataPayload(),
hasValueOperator(),
tap(() => {
this.showNotifications(true, isUpdate, false, {prefix: schema.prefix});
this.showNotifications(true, isUpdate, false, { prefix: schema.prefix });
})
);
}
@@ -244,7 +234,7 @@ export class RegistryService {
getFirstSucceededRemoteDataPayload(),
hasValueOperator(),
tap(() => {
this.showNotifications(true, false, true, {field: field.toString()});
this.showNotifications(true, false, true, { field: field.toString() });
})
);
}
@@ -259,7 +249,7 @@ export class RegistryService {
getFirstSucceededRemoteDataPayload(),
hasValueOperator(),
tap(() => {
this.showNotifications(true, true, true, {field: field.toString()});
this.showNotifications(true, true, true, { field: field.toString() });
})
);
}
@@ -271,6 +261,7 @@ export class RegistryService {
public deleteMetadataField(id: number): Observable<RestResponse> {
return this.metadataFieldService.delete(`${id}`);
}
/**
* Method that clears a cached metadata field request and returns its REST url
*/
@@ -297,13 +288,11 @@ export class RegistryService {
/**
* Retrieve a filtered paginated list of metadata fields
* @param query {string} The query to filter the field names by
* @param query {string} The query to use for the metadata field name, can be part of the fully qualified field,
* should start with the start of the schema, element or qualifier (e.g. “dc.ti”, “contributor”, “auth”, “contributor.ot”)
* @returns an observable that emits a remote data object with a page of metadata fields that match the query
*/
// TODO this is temporarily disabled. The performance is too bad.
// Querying metadatafields will need to be implemented as a search endpoint on the rest api,
// not by downloading everything and preforming the query client side.
queryMetadataFields(query: string): Observable<RemoteData<PaginatedList<MetadataField>>> {
return createSuccessfulRemoteDataObject$(new PaginatedList<MetadataField>(null, []));
queryMetadataFields(query: string, options: FindListOptions = {}, ...linksToFollow: Array<FollowLinkConfig<MetadataField>>): Observable<RemoteData<PaginatedList<MetadataField>>> {
return this.metadataFieldService.searchByFieldNameParams(null, null, null, query, null, options, ...linksToFollow);
}
}

View File

@@ -0,0 +1,47 @@
import { ReloadGuard } from './reload.guard';
import { Router } from '@angular/router';
describe('ReloadGuard', () => {
let guard: ReloadGuard;
let router: Router;
beforeEach(() => {
router = jasmine.createSpyObj('router', ['parseUrl', 'createUrlTree']);
guard = new ReloadGuard(router);
});
describe('canActivate', () => {
let route;
describe('when the route\'s query params contain a redirect url', () => {
let redirectUrl;
beforeEach(() => {
redirectUrl = '/redirect/url?param=extra';
route = {
queryParams: {
redirect: redirectUrl
}
};
});
it('should create a UrlTree with the redirect URL', () => {
guard.canActivate(route, undefined);
expect(router.parseUrl).toHaveBeenCalledWith(redirectUrl);
});
});
describe('when the route\'s query params doesn\'t contain a redirect url', () => {
beforeEach(() => {
route = {
queryParams: {}
};
});
it('should create a UrlTree to home', () => {
guard.canActivate(route, undefined);
expect(router.createUrlTree).toHaveBeenCalledWith(['home']);
});
});
});
});

View File

@@ -0,0 +1,26 @@
import { ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot, UrlTree } from '@angular/router';
import { Injectable } from '@angular/core';
import { isNotEmpty } from '../../shared/empty.util';
/**
* A guard redirecting the user to the URL provided in the route's query params
* When no redirect url is found, the user is redirected to the homepage
*/
@Injectable()
export class ReloadGuard implements CanActivate {
constructor(private router: Router) {
}
/**
* Get the UrlTree of the URL to redirect to
* @param route
* @param state
*/
canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): UrlTree {
if (isNotEmpty(route.queryParams.redirect)) {
return this.router.parseUrl(route.queryParams.redirect);
} else {
return this.router.createUrlTree(['home']);
}
}
}

View File

@@ -0,0 +1,41 @@
import {TestBed} from '@angular/core/testing';
import {BrowserHardRedirectService} from './browser-hard-redirect.service';
describe('BrowserHardRedirectService', () => {
const mockLocation = {
href: undefined,
pathname: '/pathname',
search: '/search',
} as Location;
const service: BrowserHardRedirectService = new BrowserHardRedirectService(mockLocation);
beforeEach(() => {
TestBed.configureTestingModule({});
});
it('should be created', () => {
expect(service).toBeTruthy();
});
describe('when performing a redirect', () => {
const redirect = 'test redirect';
beforeEach(() => {
service.redirect(redirect);
});
it('should update the location', () => {
expect(mockLocation.href).toEqual(redirect);
})
});
describe('when requesting the current route', () => {
it('should return the location origin', () => {
expect(service.getCurrentRoute()).toEqual(mockLocation.pathname + mockLocation.search);
});
});
});

View File

@@ -0,0 +1,35 @@
import { Inject, Injectable, InjectionToken } from '@angular/core';
import { HardRedirectService } from './hard-redirect.service';
export const LocationToken = new InjectionToken('Location');
export function locationProvider(): Location {
return window.location;
}
/**
* Service for performing hard redirects within the browser app module
*/
@Injectable()
export class BrowserHardRedirectService implements HardRedirectService {
constructor(
@Inject(LocationToken) protected location: Location,
) {
}
/**
* Perform a hard redirect to URL
* @param url
*/
redirect(url: string) {
this.location.href = url;
}
/**
* Get the origin of a request
*/
getCurrentRoute() {
return this.location.pathname + this.location.search;
}
}

View File

@@ -0,0 +1,22 @@
import { Injectable } from '@angular/core';
/**
* Service to take care of hard redirects
*/
@Injectable()
export abstract class HardRedirectService {
/**
* Perform a hard redirect to a given location.
*
* @param url
* the page to redirect to
*/
abstract redirect(url: string);
/**
* Get the current route, with query params included
* e.g. /search?page=1&query=open%20access&f.dateIssued.min=1980&f.dateIssued.max=2020
*/
abstract getCurrentRoute();
}

View File

@@ -0,0 +1,43 @@
import { TestBed } from '@angular/core/testing';
import { ServerHardRedirectService } from './server-hard-redirect.service';
describe('ServerHardRedirectService', () => {
const mockRequest = jasmine.createSpyObj(['get']);
const mockResponse = jasmine.createSpyObj(['redirect', 'end']);
const service: ServerHardRedirectService = new ServerHardRedirectService(mockRequest, mockResponse);
beforeEach(() => {
TestBed.configureTestingModule({});
});
it('should be created', () => {
expect(service).toBeTruthy();
});
describe('when performing a redirect', () => {
const redirect = 'test redirect';
beforeEach(() => {
service.redirect(redirect);
});
it('should update the response object', () => {
expect(mockResponse.redirect).toHaveBeenCalledWith(302, redirect);
expect(mockResponse.end).toHaveBeenCalled();
})
});
describe('when requesting the current route', () => {
beforeEach(() => {
mockRequest.originalUrl = 'original/url';
});
it('should return the location origin', () => {
expect(service.getCurrentRoute()).toEqual(mockRequest.originalUrl);
});
});
});

View File

@@ -0,0 +1,62 @@
import { Inject, Injectable } from '@angular/core';
import { Request, Response } from 'express';
import { REQUEST, RESPONSE } from '@nguniversal/express-engine/tokens';
import { HardRedirectService } from './hard-redirect.service';
/**
* Service for performing hard redirects within the server app module
*/
@Injectable()
export class ServerHardRedirectService implements HardRedirectService {
constructor(
@Inject(REQUEST) protected req: Request,
@Inject(RESPONSE) protected res: Response,
) {
}
/**
* Perform a hard redirect to URL
* @param url
*/
redirect(url: string) {
if (url === this.req.url) {
return;
}
if (this.res.finished) {
const req: any = this.req;
req._r_count = (req._r_count || 0) + 1;
console.warn('Attempted to redirect on a finished response. From',
this.req.url, 'to', url);
if (req._r_count > 10) {
console.error('Detected a redirection loop. killing the nodejs process');
process.exit(1);
}
} else {
// attempt to use the already set status
let status = this.res.statusCode || 0;
if (status < 300 || status >= 400) {
// temporary redirect
status = 302;
}
console.log(`Redirecting from ${this.req.url} to ${url} with ${status}`);
this.res.redirect(status, url);
this.res.end();
// I haven't found a way to correctly stop Angular rendering.
// So we just let it end its work, though we have already closed
// the response.
}
}
/**
* Get the origin of a request
*/
getCurrentRoute() {
return this.req.originalUrl;
}
}

View File

@@ -184,4 +184,25 @@ export class DSpaceObject extends ListableObject implements CacheableObject {
getRenderTypes(): Array<string | GenericConstructor<ListableObject>> {
return [this.constructor as GenericConstructor<ListableObject>];
}
setMetadata(key: string, language?: string, ...values: string[]) {
const mdValues: MetadataValue[] = values.map((value: string, index: number) => {
const md = new MetadataValue();
md.value = value;
md.authority = null;
md.confidence = -1;
md.language = language || null;
md.place = index;
return md;
});
if (hasNoValue(this.metadata)) {
this.metadata = Object.create({});
}
this.metadata[key] = mdValues;
}
removeMetadata(key: string) {
delete this.metadata[key];
}
}

View File

@@ -1,6 +1,6 @@
import { Router, UrlTree } from '@angular/router';
import { Observable } from 'rxjs';
import { filter, find, flatMap, map, take, tap } from 'rxjs/operators';
import { Observable, combineLatest as observableCombineLatest } from 'rxjs';
import { filter, find, flatMap, map, switchMap, take, tap } from 'rxjs/operators';
import { hasValue, hasValueOperator, isNotEmpty } from '../../shared/empty.util';
import { SearchResult } from '../../shared/search/search-result.model';
import { DSOSuccessResponse, RestResponse } from '../cache/response.models';
@@ -9,9 +9,12 @@ import { RemoteData } from '../data/remote-data';
import { RestRequest } from '../data/request.models';
import { RequestEntry } from '../data/request.reducer';
import { RequestService } from '../data/request.service';
import { MetadataField } from '../metadata/metadata-field.model';
import { MetadataSchema } from '../metadata/metadata-schema.model';
import { BrowseDefinition } from './browse-definition.model';
import { DSpaceObject } from './dspace-object.model';
import { getUnauthorizedRoute } from '../../app-routing-paths';
import { getEndUserAgreementPath } from '../../info/info-routing.module';
/**
* This file contains custom RxJS operators that can be used in multiple places
@@ -192,6 +195,20 @@ export const returnUnauthorizedUrlTreeOnFalse = (router: Router) =>
return authorized ? authorized : router.parseUrl(getUnauthorizedRoute())
}));
/**
* Operator that returns a UrlTree to the unauthorized page when the boolean received is false
* @param router Router
* @param redirect Redirect URL to add to the UrlTree. This is used to redirect back to the original route after the
* user accepts the agreement.
*/
export const returnEndUserAgreementUrlTreeOnFalse = (router: Router, redirect: string) =>
(source: Observable<boolean>): Observable<boolean | UrlTree> =>
source.pipe(
map((hasAgreed: boolean) => {
const queryParams = { redirect: encodeURIComponent(redirect) };
return hasAgreed ? hasAgreed : router.createUrlTree([getEndUserAgreementPath()], { queryParams });
}));
export const getFinishedRemoteData = () =>
<T>(source: Observable<RemoteData<T>>): Observable<RemoteData<T>> =>
source.pipe(find((rd: RemoteData<T>) => !rd.isLoading));
@@ -250,3 +267,27 @@ export const paginatedListToArray = () =>
hasValueOperator(),
map((objectRD: RemoteData<PaginatedList<T>>) => objectRD.payload.page.filter((object: T) => hasValue(object)))
);
/**
* Operator for turning a list of metadata fields into an array of string representing their schema.element.qualifier string
*/
export const metadataFieldsToString = () =>
(source: Observable<RemoteData<PaginatedList<MetadataField>>>): Observable<string[]> =>
source.pipe(
hasValueOperator(),
map((fieldRD: RemoteData<PaginatedList<MetadataField>>) => {
return fieldRD.payload.page.filter((object: MetadataField) => hasValue(object))
}),
switchMap((fields: MetadataField[]) => {
const fieldSchemaArray = fields.map((field: MetadataField) => {
return field.schema.pipe(
getFirstSucceededRemoteDataPayload(),
map((schema: MetadataSchema) => ({ field, schema }))
);
});
return observableCombineLatest(fieldSchemaArray);
}),
map((fieldSchemaArray: Array<{ field: MetadataField, schema: MetadataSchema }>): string[] => {
return fieldSchemaArray.map((fieldSchema: { field: MetadataField, schema: MetadataSchema }) => fieldSchema.schema.prefix + '.' + fieldSchema.field.toString())
})
);

View File

@@ -1,8 +1,6 @@
import { Observable } from 'rxjs';
import { SubmissionService } from '../../submission/submission.service';
import { RemoteData } from '../data/remote-data';
import { SubmissionObject } from './models/submission-object.model';
import { WorkspaceItem } from './models/workspaceitem.model';
import { SubmissionObjectDataService } from './submission-object-data.service';
import { SubmissionScopeType } from './submission-scope-type';
import { WorkflowItemDataService } from './workflowitem-data.service';

View File

@@ -1,4 +1,4 @@
import { Inject, Injectable } from '@angular/core';
import { Injectable } from '@angular/core';
import { deepClone } from 'fast-json-patch';
import { DSOResponseParsingService } from '../data/dso-response-parsing.service';
@@ -113,7 +113,7 @@ export class SubmissionResponseParsingService extends BaseResponseParsingService
return new ErrorResponse(
Object.assign(
new Error('Unexpected response from server'),
{statusCode: data.statusCode, statusText: data.statusText}
{ statusCode: data.statusCode, statusText: data.statusText }
)
);
}
@@ -133,7 +133,7 @@ export class SubmissionResponseParsingService extends BaseResponseParsingService
processedList.forEach((item) => {
item = Object.assign({}, item);
// item = Object.assign({}, item);
// In case data is an Instance of WorkspaceItem normalize field value of all the section of type form
if (item instanceof WorkspaceItem
|| item instanceof WorkflowItem) {

View File

@@ -0,0 +1,12 @@
import { ResourceType } from '../../../shared/resource-type';
/**
* The resource type for vocabulary models
*
* Needs to be in a separate file to prevent circular
* dependencies in webpack.
*/
export const VOCABULARY = new ResourceType('vocabulary');
export const VOCABULARY_ENTRY = new ResourceType('vocabularyEntry');
export const VOCABULARY_ENTRY_DETAIL = new ResourceType('vocabularyEntryDetail');

Some files were not shown because too many files have changed in this diff Show More