mirror of
https://github.com/DSpace/dspace-angular.git
synced 2025-10-07 10:04:11 +00:00
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:
21
.codecov.yml
Normal file
21
.codecov.yml
Normal 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
|
@@ -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
|
||||
|
@@ -1,4 +1,4 @@
|
||||
[](https://travis-ci.com/DSpace/dspace-angular) [](https://coveralls.io/github/DSpace/dspace-angular?branch=main) [](https://github.com/angular/universal)
|
||||
[](https://travis-ci.com/DSpace/dspace-angular) [](https://codecov.io/gh/DSpace/dspace-angular) [](https://github.com/angular/universal)
|
||||
|
||||
dspace-angular
|
||||
==============
|
||||
|
@@ -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",
|
||||
|
54
server.ts
54
server.ts
@@ -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,37 +130,7 @@ 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,
|
||||
@@ -171,16 +140,21 @@ function ngApp(req, res) {
|
||||
baseUrl: environment.ui.nameSpace,
|
||||
originUrl: environment.ui.baseUrl,
|
||||
requestUrl: req.originalUrl
|
||||
});
|
||||
});
|
||||
}, (err, data) => {
|
||||
if (hasNoValue(err) && hasValue(data)) {
|
||||
res.send(data);
|
||||
} else {
|
||||
// If preboot is disabled, just serve the client side ejs template and pass it the required
|
||||
// variables
|
||||
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
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -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);
|
||||
}
|
||||
}
|
@@ -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 {
|
||||
|
@@ -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);
|
||||
}
|
||||
}
|
@@ -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 {
|
||||
|
@@ -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);
|
||||
}
|
||||
|
||||
|
@@ -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 {
|
||||
|
@@ -6,13 +6,14 @@
|
||||
<div *ngIf="(editable | async)" class="field-container">
|
||||
<ds-filter-input-suggestions [suggestions]="(metadataFieldSuggestions | async)"
|
||||
[(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>
|
||||
|
@@ -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('.​'), value: mdField1.toString() },
|
||||
{ displayValue: mdField2.toString().split('.').join('.​'), value: mdField2.toString() },
|
||||
{ displayValue: mdField3.toString().split('.').join('.​'), value: mdField3.toString() }
|
||||
{ displayValue: ('dc.' + mdField1.toString()).split('.').join('.​'), value: ('dc.' + mdField1.toString()) },
|
||||
{ displayValue: ('dc.' + mdField2.toString()).split('.').join('.​'), value: ('dc.' + mdField2.toString()) },
|
||||
{ displayValue: ('dc.' + mdField3.toString()).split('.').join('.​'), 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', () => {
|
||||
|
@@ -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('.​'),
|
||||
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('.​'),
|
||||
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
|
||||
|
@@ -16,7 +16,7 @@
|
||||
class="fas fa-undo-alt"></i>
|
||||
<span class="d-none d-sm-inline"> {{"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"> {{"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,
|
||||
|
@@ -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)
|
||||
*/
|
||||
|
@@ -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);
|
||||
}
|
||||
}
|
@@ -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);
|
||||
}
|
||||
}
|
@@ -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>
|
||||
|
@@ -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();
|
||||
}));
|
||||
|
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
30
src/app/+item-page/item-page-administrator.guard.ts
Normal file
30
src/app/+item-page/item-page-administrator.guard.ts
Normal 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);
|
||||
}
|
||||
}
|
@@ -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
|
||||
]
|
||||
|
||||
})
|
||||
|
@@ -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}`;
|
||||
}
|
||||
|
@@ -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,54 +13,64 @@ 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: '', canActivate: [AuthBlockingGuard],
|
||||
children: [
|
||||
{ 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: '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]
|
||||
canActivate: [AuthenticatedGuard, EndUserAgreementCurrentUserGuard]
|
||||
},
|
||||
{ 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: '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' },
|
||||
{ path: 'import-external', loadChildren: './+import-external-page/import-external-page.module#ImportExternalPageModule' },
|
||||
{ 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'
|
||||
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'
|
||||
loadChildren: './+workflowitems-edit-page/workflowitems-edit-page.module#WorkflowItemsEditPageModule',
|
||||
canActivate: [EndUserAgreementCurrentUserGuard]
|
||||
},
|
||||
{
|
||||
path: PROFILE_MODULE_PATH,
|
||||
loadChildren: './profile-page/profile-page.module#ProfilePageModule', canActivate: [AuthenticatedGuard]
|
||||
loadChildren: './profile-page/profile-page.module#ProfilePageModule', canActivate: [AuthenticatedGuard, EndUserAgreementCurrentUserGuard]
|
||||
},
|
||||
{ path: 'processes', loadChildren: './process-page/process-page.module#ProcessPageModule', canActivate: [AuthenticatedGuard] },
|
||||
{ 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',
|
||||
|
@@ -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>
|
||||
|
@@ -47,3 +47,7 @@ ds-admin-sidebar {
|
||||
position: fixed;
|
||||
z-index: $sidebar-z-index;
|
||||
}
|
||||
|
||||
.ds-full-screen-loader {
|
||||
height: 100vh;
|
||||
}
|
||||
|
@@ -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) => {
|
||||
|
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -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,
|
||||
];
|
||||
|
||||
|
@@ -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,
|
||||
|
62
src/app/core/auth/auth-blocking.guard.spec.ts
Normal file
62
src/app/core/auth/auth-blocking.guard.spec.ts
Normal 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 }));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
});
|
34
src/app/core/auth/auth-blocking.guard.ts
Normal file
34
src/app/core/auth/auth-blocking.guard.ts
Normal 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),
|
||||
);
|
||||
}
|
||||
|
||||
}
|
@@ -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;
|
||||
|
@@ -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(
|
||||
|
@@ -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)) {
|
||||
|
@@ -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)]
|
||||
};
|
||||
|
@@ -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;
|
||||
}
|
||||
|
@@ -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', () => {
|
||||
|
@@ -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);
|
||||
return this.store.pipe(
|
||||
select(getRedirectUrl),
|
||||
map((urlFromStore: string) => {
|
||||
if (hasValue(urlFromStore)) {
|
||||
return urlFromStore;
|
||||
} else {
|
||||
return this.store.pipe(select(getRedirectUrl));
|
||||
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
|
||||
*/
|
||||
|
@@ -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;
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@@ -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
|
||||
|
@@ -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] || '');
|
||||
});
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
|
12
src/app/core/cache/response.models.ts
vendored
12
src/app/core/cache/response.models.ts
vendored
@@ -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,
|
||||
|
@@ -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
|
||||
];
|
||||
|
||||
|
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
@@ -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';
|
||||
|
@@ -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';
|
||||
|
@@ -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(
|
||||
|
@@ -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¶m2=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`;
|
||||
|
||||
|
@@ -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);
|
||||
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);
|
||||
}
|
||||
));
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
54
src/app/core/data/entries-response-parsing.service.ts
Normal file
54
src/app/core/data/entries-response-parsing.service.ts
Normal 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 }
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@@ -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', () => {
|
||||
|
@@ -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);
|
||||
})
|
||||
|
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -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);
|
||||
}
|
||||
}
|
||||
|
@@ -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);
|
||||
}
|
||||
}
|
||||
|
@@ -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);
|
||||
}
|
||||
}
|
@@ -3,5 +3,8 @@
|
||||
*/
|
||||
export enum FeatureID {
|
||||
LoginOnBehalfOf = 'loginOnBehalfOf',
|
||||
AdministratorOf = 'administratorOf'
|
||||
AdministratorOf = 'administratorOf',
|
||||
WithdrawItem = 'withdrawItem',
|
||||
ReinstateItem = 'reinstateItem',
|
||||
EPersonRegistration = 'epersonRegistration',
|
||||
}
|
||||
|
@@ -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
|
||||
|
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
@@ -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>;
|
||||
|
||||
}
|
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
@@ -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());
|
||||
}
|
||||
|
||||
}
|
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
@@ -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);
|
||||
}
|
||||
|
||||
}
|
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
111
src/app/core/end-user-agreement/end-user-agreement.service.ts
Normal file
111
src/app/core/end-user-agreement/end-user-agreement.service.ts
Normal 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);
|
||||
}
|
||||
|
||||
}
|
@@ -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';
|
||||
|
@@ -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();
|
||||
}
|
||||
|
||||
}
|
@@ -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[]
|
||||
) { }
|
||||
}
|
@@ -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);
|
||||
});
|
||||
|
||||
});
|
||||
});
|
@@ -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;
|
||||
}
|
||||
|
||||
}
|
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
@@ -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());
|
||||
}
|
||||
|
||||
}
|
@@ -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;
|
||||
}
|
||||
}
|
@@ -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');
|
@@ -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;
|
||||
}
|
||||
}
|
@@ -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) {
|
||||
|
||||
}
|
||||
}
|
@@ -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
|
||||
}
|
||||
|
||||
}
|
@@ -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);
|
||||
}
|
||||
|
@@ -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 = '';
|
||||
|
@@ -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
|
||||
|
@@ -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;
|
||||
|
@@ -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);
|
||||
}
|
||||
}
|
||||
|
47
src/app/core/reload/reload.guard.spec.ts
Normal file
47
src/app/core/reload/reload.guard.spec.ts
Normal 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']);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
26
src/app/core/reload/reload.guard.ts
Normal file
26
src/app/core/reload/reload.guard.ts
Normal 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']);
|
||||
}
|
||||
}
|
||||
}
|
41
src/app/core/services/browser-hard-redirect.service.spec.ts
Normal file
41
src/app/core/services/browser-hard-redirect.service.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
35
src/app/core/services/browser-hard-redirect.service.ts
Normal file
35
src/app/core/services/browser-hard-redirect.service.ts
Normal 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;
|
||||
}
|
||||
}
|
22
src/app/core/services/hard-redirect.service.ts
Normal file
22
src/app/core/services/hard-redirect.service.ts
Normal 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();
|
||||
}
|
43
src/app/core/services/server-hard-redirect.service.spec.ts
Normal file
43
src/app/core/services/server-hard-redirect.service.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
62
src/app/core/services/server-hard-redirect.service.ts
Normal file
62
src/app/core/services/server-hard-redirect.service.ts
Normal 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;
|
||||
}
|
||||
}
|
@@ -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];
|
||||
}
|
||||
|
||||
}
|
||||
|
@@ -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())
|
||||
})
|
||||
);
|
||||
|
@@ -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';
|
||||
|
@@ -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) {
|
||||
|
@@ -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
Reference in New Issue
Block a user