Merge remote-tracking branch 'remotes/origin/master' into dynamic_forms

# Conflicts:
#	resources/i18n/en.json
#	src/app/core/cache/response-cache.models.ts
#	src/app/core/data/request.models.ts
#	src/app/shared/shared.module.ts
#	src/styles/_custom_variables.scss
This commit is contained in:
Giuseppe Digilio
2018-07-12 14:56:57 +02:00
133 changed files with 6196 additions and 108 deletions

View File

@@ -23,9 +23,6 @@ git clone https://github.com/DSpace/dspace-angular.git
# change directory to our repo # change directory to our repo
cd dspace-angular cd dspace-angular
# install the global dependencies
yarn run global
# install the local dependencies # install the local dependencies
yarn install yarn install

View File

@@ -104,8 +104,10 @@
"font-awesome": "4.7.0", "font-awesome": "4.7.0",
"http-server": "0.11.1", "http-server": "0.11.1",
"https": "1.0.0", "https": "1.0.0",
"js-cookie": "2.2.0",
"js.clone": "0.0.3", "js.clone": "0.0.3",
"jsonschema": "1.2.2", "jsonschema": "1.2.2",
"jwt-decode": "^2.2.0",
"methods": "1.1.2", "methods": "1.1.2",
"morgan": "1.9.0", "morgan": "1.9.0",
"ng2-file-upload": "1.2.1", "ng2-file-upload": "1.2.1",
@@ -133,6 +135,7 @@
"@types/express-serve-static-core": "4.11.1", "@types/express-serve-static-core": "4.11.1",
"@types/hammerjs": "2.0.35", "@types/hammerjs": "2.0.35",
"@types/jasmine": "^2.8.6", "@types/jasmine": "^2.8.6",
"@types/js-cookie": "2.1.0",
"@types/memory-cache": "0.2.0", "@types/memory-cache": "0.2.0",
"@types/mime": "2.0.0", "@types/mime": "2.0.0",
"@types/node": "^9.4.6", "@types/node": "^9.4.6",

View File

@@ -46,7 +46,9 @@
} }
}, },
"nav": { "nav": {
"home": "Home" "home": "Home",
"login": "Log In",
"logout": "Log Out"
}, },
"pagination": { "pagination": {
"results-per-page": "Results Per Page", "results-per-page": "Results Per Page",
@@ -133,6 +135,55 @@
} }
} }
}, },
"admin": {
"registries": {
"metadata": {
"title": "DSpace Angular :: Metadata Registry",
"head": "Metadata Registry",
"description": "The metadata registry maintains a list of all metadata fields available in the repository. These fields may be divided amongst multiple schemas. However, DSpace requires the qualified Dublin Core schema.",
"schemas": {
"table": {
"id": "ID",
"namespace": "Namespace",
"name": "Name"
},
"no-items": "No metadata schemas to show."
}
},
"schema": {
"title": "DSpace Angular :: Metadata Schema Registry",
"head": "Metadata Schema",
"description": "This is the metadata schema for \"{{namespace}}\".",
"fields": {
"head": "Schema metadata fields",
"table": {
"field": "Field",
"scopenote": "Scope Note"
},
"no-items": "No metadata fields to show."
}
},
"bitstream-formats": {
"title": "DSpace Angular :: Bitstream Format Registry",
"head": "Bitstream Format Registry",
"description": "This list of bitstream formats provides information about known formats and their support level.",
"formats": {
"table": {
"name": "Name",
"mimetype": "MIME Type",
"supportLevel": {
"head": "Support Level",
"0": "Unknown",
"1": "Known",
"2": "Support"
},
"internal": "internal"
},
"no-items": "No bitstream formats to show."
}
}
}
},
"loading": { "loading": {
"default": "Loading...", "default": "Loading...",
"top-level-communities": "Loading top level communities...", "top-level-communities": "Loading top level communities...",
@@ -175,5 +226,31 @@
"group-expand": "Expand", "group-expand": "Expand",
"group-collapse-help": "Click here to collapse", "group-collapse-help": "Click here to collapse",
"group-expand-help": "Click here to expand and add more element" "group-expand-help": "Click here to expand and add more element"
},
"login": {
"title": "Login",
"form": {
"header": "Please log in to DSpace",
"email": "Email address",
"forgot-password": "Have you forgotten your password?",
"new-user": "New user? Click here to register.",
"password": "Password",
"submit": "Log in"
}
},
"logout": {
"title": "Logout",
"form": {
"header": "Log out from DSpace",
"submit": "Log out"
}
},
"auth": {
"messages": {
"expired": "Your session has expired. Please log in again."
},
"errors": {
"invalid-user": "Invalid email or password."
}
} }
} }

View File

@@ -0,0 +1,18 @@
import { MetadataRegistryComponent } from './metadata-registry/metadata-registry.component';
import { RouterModule } from '@angular/router';
import { NgModule } from '@angular/core';
import { MetadataSchemaComponent } from './metadata-schema/metadata-schema.component';
import { BitstreamFormatsComponent } from './bitstream-formats/bitstream-formats.component';
@NgModule({
imports: [
RouterModule.forChild([
{ path: 'metadata', component: MetadataRegistryComponent, data: { title: 'admin.registries.metadata.title' } },
{ path: 'metadata/:schemaName', component: MetadataSchemaComponent, data: { title: 'admin.registries.schema.title' } },
{ path: 'bitstream-formats', component: BitstreamFormatsComponent, data: { title: 'admin.registries.bitstream-formats.title' } },
])
]
})
export class AdminRegistriesRoutingModule {
}

View File

@@ -0,0 +1,27 @@
import { NgModule } from '@angular/core';
import { MetadataRegistryComponent } from './metadata-registry/metadata-registry.component';
import { AdminRegistriesRoutingModule } from './admin-registries-routing.module';
import { CommonModule } from '@angular/common';
import { MetadataSchemaComponent } from './metadata-schema/metadata-schema.component';
import { RouterModule } from '@angular/router';
import { TranslateModule } from '@ngx-translate/core';
import { BitstreamFormatsComponent } from './bitstream-formats/bitstream-formats.component';
import { SharedModule } from '../../shared/shared.module';
@NgModule({
imports: [
CommonModule,
SharedModule,
RouterModule,
TranslateModule,
AdminRegistriesRoutingModule
],
declarations: [
MetadataRegistryComponent,
MetadataSchemaComponent,
BitstreamFormatsComponent
]
})
export class AdminRegistriesModule {
}

View File

@@ -0,0 +1,42 @@
<div class="container">
<div class="bitstream-formats row">
<div class="col-12">
<h2 id="header" class="border-bottom pb-2">{{'admin.registries.bitstream-formats.head' | translate}}</h2>
<p id="description" class="pb-2">{{'admin.registries.bitstream-formats.description' | translate}}</p>
<ds-pagination
*ngIf="(bitstreamFormats | async)?.payload?.totalElements > 0"
[paginationOptions]="config"
[pageInfoState]="(bitstreamFormats | async)?.payload"
[collectionSize]="(bitstreamFormats | async)?.payload?.totalElements"
[hideGear]="true"
[hidePagerWhenSinglePage]="true"
(pageChange)="onPageChange($event)">
<div class="table-responsive">
<table id="formats" class="table table-striped table-hover">
<thead>
<tr>
<th scope="col">{{'admin.registries.bitstream-formats.formats.table.name' | translate}}</th>
<th scope="col">{{'admin.registries.bitstream-formats.formats.table.mimetype' | translate}}</th>
<th scope="col">{{'admin.registries.bitstream-formats.formats.table.supportLevel.head' | translate}}</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let bitstreamFormat of (bitstreamFormats | async)?.payload?.page">
<td>{{bitstreamFormat.shortDescription}}</td>
<td>{{bitstreamFormat.mimetype}} <span *ngIf="bitstreamFormat.internal">({{'admin.registries.bitstream-formats.formats.table.internal' | translate}})</span></td>
<td>{{'admin.registries.bitstream-formats.formats.table.supportLevel.'+bitstreamFormat.supportLevel | translate}}</td>
</tr>
</tbody>
</table>
</div>
</ds-pagination>
<div *ngIf="(bitstreamFormats | async)?.payload?.totalElements == 0" class="alert alert-info" role="alert">
{{'admin.registries.bitstream-formats.formats.no-items' | translate}}
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,98 @@
import { BitstreamFormatsComponent } from './bitstream-formats.component';
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { RegistryService } from '../../../core/registry/registry.service';
import { Observable } from 'rxjs/Observable';
import { RemoteData } from '../../../core/data/remote-data';
import { PaginatedList } from '../../../core/data/paginated-list';
import { CommonModule } from '@angular/common';
import { RouterTestingModule } from '@angular/router/testing';
import { TranslateModule } from '@ngx-translate/core';
import { By } from '@angular/platform-browser';
import { SharedModule } from '../../../shared/shared.module';
import { PaginationComponent } from '../../../shared/pagination/pagination.component';
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
import { EnumKeysPipe } from '../../../shared/utils/enum-keys-pipe';
import { HostWindowService } from '../../../shared/host-window.service';
import { HostWindowServiceStub } from '../../../shared/testing/host-window-service-stub';
describe('BitstreamFormatsComponent', () => {
let comp: BitstreamFormatsComponent;
let fixture: ComponentFixture<BitstreamFormatsComponent>;
let registryService: RegistryService;
const mockFormatsList = [
{
shortDescription: 'Unknown',
description: 'Unknown data format',
mimetype: 'application/octet-stream',
supportLevel: 0,
internal: false,
extensions: null
},
{
shortDescription: 'License',
description: 'Item-specific license agreed upon to submission',
mimetype: 'text/plain; charset=utf-8',
supportLevel: 1,
internal: true,
extensions: null
},
{
shortDescription: 'CC License',
description: 'Item-specific Creative Commons license agreed upon to submission',
mimetype: 'text/html; charset=utf-8',
supportLevel: 2,
internal: true,
extensions: null
},
{
shortDescription: 'Adobe PDF',
description: 'Adobe Portable Document Format',
mimetype: 'application/pdf',
supportLevel: 0,
internal: false,
extensions: null
}
];
const mockFormats = Observable.of(new RemoteData(false, false, true, undefined, new PaginatedList(null, mockFormatsList)));
const registryServiceStub = {
getBitstreamFormats: () => mockFormats
};
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [CommonModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule.forRoot()],
declarations: [BitstreamFormatsComponent, PaginationComponent, EnumKeysPipe],
providers: [
{ provide: RegistryService, useValue: registryServiceStub },
{ provide: HostWindowService, useValue: new HostWindowServiceStub(0) }
]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(BitstreamFormatsComponent);
comp = fixture.componentInstance;
fixture.detectChanges();
registryService = (comp as any).service;
});
it('should contain four formats', () => {
const tbody: HTMLElement = fixture.debugElement.query(By.css('#formats>tbody')).nativeElement;
expect(tbody.children.length).toBe(4);
});
it('should contain the correct formats', () => {
const unknownName: HTMLElement = fixture.debugElement.query(By.css('#formats tr:nth-child(1) td:nth-child(1)')).nativeElement;
expect(unknownName.textContent).toBe('Unknown');
const licenseName: HTMLElement = fixture.debugElement.query(By.css('#formats tr:nth-child(2) td:nth-child(1)')).nativeElement;
expect(licenseName.textContent).toBe('License');
const ccLicenseName: HTMLElement = fixture.debugElement.query(By.css('#formats tr:nth-child(3) td:nth-child(1)')).nativeElement;
expect(ccLicenseName.textContent).toBe('CC License');
const adobeName: HTMLElement = fixture.debugElement.query(By.css('#formats tr:nth-child(4) td:nth-child(1)')).nativeElement;
expect(adobeName.textContent).toBe('Adobe PDF');
});
});

View File

@@ -0,0 +1,33 @@
import { Component } from '@angular/core';
import { RegistryService } from '../../../core/registry/registry.service';
import { Observable } from 'rxjs/Observable';
import { RemoteData } from '../../../core/data/remote-data';
import { PaginatedList } from '../../../core/data/paginated-list';
import { BitstreamFormat } from '../../../core/registry/mock-bitstream-format.model';
import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model';
@Component({
selector: 'ds-bitstream-formats',
templateUrl: './bitstream-formats.component.html'
})
export class BitstreamFormatsComponent {
bitstreamFormats: Observable<RemoteData<PaginatedList<BitstreamFormat>>>;
config: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), {
id: 'registry-bitstreamformats-pagination',
pageSize: 10000
});
constructor(private registryService: RegistryService) {
this.updateFormats();
}
onPageChange(event) {
this.config.currentPage = event;
this.updateFormats();
}
private updateFormats() {
this.bitstreamFormats = this.registryService.getBitstreamFormats(this.config);
}
}

View File

@@ -0,0 +1,42 @@
<div class="container">
<div class="metadata-registry row">
<div class="col-12">
<h2 id="header" class="border-bottom pb-2">{{'admin.registries.metadata.head' | translate}}</h2>
<p id="description" class="pb-2">{{'admin.registries.metadata.description' | translate}}</p>
<ds-pagination
*ngIf="(metadataSchemas | async)?.payload?.totalElements > 0"
[paginationOptions]="config"
[pageInfoState]="(metadataSchemas | async)?.payload"
[collectionSize]="(metadataSchemas | async)?.payload?.totalElements"
[hideGear]="true"
[hidePagerWhenSinglePage]="true"
(pageChange)="onPageChange($event)">
<div class="table-responsive">
<table id="metadata-schemas" class="table table-striped table-hover">
<thead>
<tr>
<th scope="col">{{'admin.registries.metadata.schemas.table.id' | translate}}</th>
<th scope="col">{{'admin.registries.metadata.schemas.table.namespace' | translate}}</th>
<th scope="col">{{'admin.registries.metadata.schemas.table.name' | translate}}</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let schema of (metadataSchemas | async)?.payload?.page">
<td><a [routerLink]="[schema.prefix]">{{schema.id}}</a></td>
<td><a [routerLink]="[schema.prefix]">{{schema.namespace}}</a></td>
<td><a [routerLink]="[schema.prefix]">{{schema.prefix}}</a></td>
</tr>
</tbody>
</table>
</div>
</ds-pagination>
<div *ngIf="(metadataSchemas | async)?.payload?.totalElements == 0" class="alert alert-info" role="alert">
{{'admin.registries.metadata.schemas.no-items' | translate}}
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,72 @@
import { MetadataRegistryComponent } from './metadata-registry.component';
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { Observable } from 'rxjs/Observable';
import { RemoteData } from '../../../core/data/remote-data';
import { PaginatedList } from '../../../core/data/paginated-list';
import { TranslateModule } from '@ngx-translate/core';
import { By } from '@angular/platform-browser';
import { CommonModule } from '@angular/common';
import { RouterTestingModule } from '@angular/router/testing';
import { RegistryService } from '../../../core/registry/registry.service';
import { SharedModule } from '../../../shared/shared.module';
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
import { EnumKeysPipe } from '../../../shared/utils/enum-keys-pipe';
import { PaginationComponent } from '../../../shared/pagination/pagination.component';
import { HostWindowServiceStub } from '../../../shared/testing/host-window-service-stub';
import { HostWindowService } from '../../../shared/host-window.service';
describe('MetadataRegistryComponent', () => {
let comp: MetadataRegistryComponent;
let fixture: ComponentFixture<MetadataRegistryComponent>;
let registryService: RegistryService;
const mockSchemasList = [
{
id: 1,
self: 'https://dspace7.4science.it/dspace-spring-rest/api/core/metadataschemas/1',
prefix: 'dc',
namespace: 'http://dublincore.org/documents/dcmi-terms/'
},
{
id: 2,
self: 'https://dspace7.4science.it/dspace-spring-rest/api/core/metadataschemas/2',
prefix: 'mock',
namespace: 'http://dspace.org/mockschema'
}
];
const mockSchemas = Observable.of(new RemoteData(false, false, true, undefined, new PaginatedList(null, mockSchemasList)));
const registryServiceStub = {
getMetadataSchemas: () => mockSchemas
};
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [CommonModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule.forRoot()],
declarations: [MetadataRegistryComponent, PaginationComponent, EnumKeysPipe],
providers: [
{ provide: RegistryService, useValue: registryServiceStub },
{ provide: HostWindowService, useValue: new HostWindowServiceStub(0) }
]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(MetadataRegistryComponent);
comp = fixture.componentInstance;
fixture.detectChanges();
registryService = (comp as any).service;
});
it('should contain two schemas', () => {
const tbody: HTMLElement = fixture.debugElement.query(By.css('#metadata-schemas>tbody')).nativeElement;
expect(tbody.children.length).toBe(2);
});
it('should contain the correct schemas', () => {
const dcName: HTMLElement = fixture.debugElement.query(By.css('#metadata-schemas tr:nth-child(1) td:nth-child(3)')).nativeElement;
expect(dcName.textContent).toBe('dc');
const mockName: HTMLElement = fixture.debugElement.query(By.css('#metadata-schemas tr:nth-child(2) td:nth-child(3)')).nativeElement;
expect(mockName.textContent).toBe('mock');
});
});

View File

@@ -0,0 +1,34 @@
import { Component } from '@angular/core';
import { RegistryService } from '../../../core/registry/registry.service';
import { Observable } from 'rxjs/Observable';
import { RemoteData } from '../../../core/data/remote-data';
import { PaginatedList } from '../../../core/data/paginated-list';
import { MetadataSchema } from '../../../core/metadata/metadataschema.model';
import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model';
@Component({
selector: 'ds-metadata-registry',
templateUrl: './metadata-registry.component.html'
})
export class MetadataRegistryComponent {
metadataSchemas: Observable<RemoteData<PaginatedList<MetadataSchema>>>;
config: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), {
id: 'registry-metadataschemas-pagination',
pageSize: 10000
});
constructor(private registryService: RegistryService) {
this.updateSchemas();
}
onPageChange(event) {
this.config.currentPage = event;
this.updateSchemas();
}
private updateSchemas() {
this.metadataSchemas = this.registryService.getMetadataSchemas(this.config);
}
}

View File

@@ -0,0 +1,41 @@
<div class="container">
<div class="metadata-schema row">
<div class="col-12">
<h2 id="header" class="border-bottom pb-2">{{'admin.registries.schema.head' | translate}}: "{{(metadataSchema | async)?.payload?.prefix}}"</h2>
<p id="description" class="pb-2">{{'admin.registries.schema.description' | translate:namespace }}</p>
<h3>{{'admin.registries.schema.fields.head' | translate}}</h3>
<ds-pagination
*ngIf="(metadataFields | async)?.payload?.totalElements > 0"
[paginationOptions]="config"
[pageInfoState]="(metadataFields | async)?.payload"
[collectionSize]="(metadataFields | async)?.payload?.totalElements"
[hideGear]="true"
[hidePagerWhenSinglePage]="true"
(pageChange)="onPageChange($event)">
<div class="table-responsive">
<table id="metadata-fields" class="table table-striped table-hover">
<thead>
<tr>
<th scope="col">{{'admin.registries.schema.fields.table.field' | translate}}</th>
<th scope="col">{{'admin.registries.schema.fields.table.scopenote' | translate}}</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let field of (metadataFields | async)?.payload?.page">
<td>{{(metadataSchema | async)?.payload?.prefix}}.{{field.element}}<label *ngIf="field.qualifier">.</label>{{field.qualifier}}</td>
<td>{{field.scopeNote}}</td>
</tr>
</tbody>
</table>
</div>
</ds-pagination>
<div *ngIf="(metadataFields | async)?.payload?.totalElements == 0" class="alert alert-info" role="alert">
{{'admin.registries.schema.fields.no-items' | translate}}
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,121 @@
import { MetadataSchemaComponent } from './metadata-schema.component';
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { Observable } from 'rxjs/Observable';
import { RemoteData } from '../../../core/data/remote-data';
import { PaginatedList } from '../../../core/data/paginated-list';
import { MetadataSchema } from '../../../core/metadata/metadataschema.model';
import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router } from '@angular/router';
import { By } from '@angular/platform-browser';
import { MockTranslateLoader } from '../../../shared/testing/mock-translate-loader';
import { RegistryService } from '../../../core/registry/registry.service';
import { SharedModule } from '../../../shared/shared.module';
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
import { EnumKeysPipe } from '../../../shared/utils/enum-keys-pipe';
import { PaginationComponent } from '../../../shared/pagination/pagination.component';
import { HostWindowServiceStub } from '../../../shared/testing/host-window-service-stub';
import { HostWindowService } from '../../../shared/host-window.service';
import { RouterStub } from '../../../shared/testing/router-stub';
import { RouterTestingModule } from '@angular/router/testing';
import { ActivatedRouteStub } from '../../../shared/testing/active-router-stub';
describe('MetadataSchemaComponent', () => {
let comp: MetadataSchemaComponent;
let fixture: ComponentFixture<MetadataSchemaComponent>;
let registryService: RegistryService;
const mockSchemasList = [
{
id: 1,
self: 'https://dspace7.4science.it/dspace-spring-rest/api/core/metadataschemas/1',
prefix: 'dc',
namespace: 'http://dublincore.org/documents/dcmi-terms/'
},
{
id: 2,
self: 'https://dspace7.4science.it/dspace-spring-rest/api/core/metadataschemas/2',
prefix: 'mock',
namespace: 'http://dspace.org/mockschema'
}
];
const mockFieldsList = [
{
self: 'https://dspace7.4science.it/dspace-spring-rest/api/core/metadatafields/8',
element: 'contributor',
qualifier: 'advisor',
scopenote: null,
schema: mockSchemasList[0]
},
{
self: 'https://dspace7.4science.it/dspace-spring-rest/api/core/metadatafields/9',
element: 'contributor',
qualifier: 'author',
scopenote: null,
schema: mockSchemasList[0]
},
{
self: 'https://dspace7.4science.it/dspace-spring-rest/api/core/metadatafields/10',
element: 'contributor',
qualifier: 'editor',
scopenote: 'test scope note',
schema: mockSchemasList[1]
},
{
self: 'https://dspace7.4science.it/dspace-spring-rest/api/core/metadatafields/11',
element: 'contributor',
qualifier: 'illustrator',
scopenote: null,
schema: mockSchemasList[1]
}
];
const mockSchemas = Observable.of(new RemoteData(false, false, true, undefined, new PaginatedList(null, mockSchemasList)));
const registryServiceStub = {
getMetadataSchemas: () => mockSchemas,
getMetadataFieldsBySchema: (schema: MetadataSchema) => Observable.of(new RemoteData(false, false, true, undefined, new PaginatedList(null, mockFieldsList.filter((value) => value.schema === schema)))),
getMetadataSchemaByName: (schemaName: string) => Observable.of(new RemoteData(false, false, true, undefined, mockSchemasList.filter((value) => value.prefix === schemaName)[0]))
};
const schemaNameParam = 'mock';
const activatedRouteStub = Object.assign(new ActivatedRouteStub(), {
params: Observable.of({
schemaName: schemaNameParam
})
});
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [CommonModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule.forRoot()],
declarations: [MetadataSchemaComponent, PaginationComponent, EnumKeysPipe],
providers: [
{ provide: RegistryService, useValue: registryServiceStub },
{ provide: ActivatedRoute, useValue: activatedRouteStub },
{ provide: HostWindowService, useValue: new HostWindowServiceStub(0) },
{ provide: Router, useValue: new RouterStub() }
]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(MetadataSchemaComponent);
comp = fixture.componentInstance;
fixture.detectChanges();
registryService = (comp as any).service;
});
it('should contain the schema prefix in the header', () => {
const header: HTMLElement = fixture.debugElement.query(By.css('.metadata-schema #header')).nativeElement;
expect(header.textContent).toContain('mock');
});
it('should contain two fields', () => {
const tbody: HTMLElement = fixture.debugElement.query(By.css('#metadata-fields>tbody')).nativeElement;
expect(tbody.children.length).toBe(2);
});
it('should contain the correct fields', () => {
const editorField: HTMLElement = fixture.debugElement.query(By.css('#metadata-fields tr:nth-child(1) td:nth-child(1)')).nativeElement;
expect(editorField.textContent).toBe('mock.contributor.editor');
const illustratorField: HTMLElement = fixture.debugElement.query(By.css('#metadata-fields tr:nth-child(2) td:nth-child(1)')).nativeElement;
expect(illustratorField.textContent).toBe('mock.contributor.illustrator');
});
});

View File

@@ -0,0 +1,55 @@
import { Component, OnInit } from '@angular/core';
import { RegistryService } from '../../../core/registry/registry.service';
import { ActivatedRoute } from '@angular/router';
import { Observable } from 'rxjs/Observable';
import { RemoteData } from '../../../core/data/remote-data';
import { PaginatedList } from '../../../core/data/paginated-list';
import { MetadataField } from '../../../core/metadata/metadatafield.model';
import { MetadataSchema } from '../../../core/metadata/metadataschema.model';
import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model';
import { SortOptions } from '../../../core/cache/models/sort-options.model';
@Component({
selector: 'ds-metadata-schema',
templateUrl: './metadata-schema.component.html'
})
export class MetadataSchemaComponent implements OnInit {
namespace;
metadataSchema: Observable<RemoteData<MetadataSchema>>;
metadataFields: Observable<RemoteData<PaginatedList<MetadataField>>>;
config: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), {
id: 'registry-metadatafields-pagination',
pageSize: 10000
});
constructor(private registryService: RegistryService, private route: ActivatedRoute) {
}
ngOnInit(): void {
this.route.params.subscribe((params) => {
this.initialize(params);
});
}
initialize(params) {
this.metadataSchema = this.registryService.getMetadataSchemaByName(params.schemaName);
this.updateFields();
}
onPageChange(event) {
this.config.currentPage = event;
this.updateFields();
}
private updateFields() {
this.metadataSchema.subscribe((schemaData) => {
const schema = schemaData.payload;
this.metadataFields = this.registryService.getMetadataFieldsBySchema(schema, this.config);
this.namespace = { namespace: schemaData.payload.namespace };
});
}
}

View File

@@ -0,0 +1,13 @@
import { RouterModule } from '@angular/router';
import { NgModule } from '@angular/core';
@NgModule({
imports: [
RouterModule.forChild([
{ path: 'registries', loadChildren: './admin-registries/admin-registries.module#AdminRegistriesModule' }
])
]
})
export class AdminRoutingModule {
}

View File

@@ -0,0 +1,13 @@
import { NgModule } from '@angular/core';
import { AdminRegistriesModule } from './admin-registries/admin-registries.module';
import { AdminRoutingModule } from './admin-routing.module';
@NgModule({
imports: [
AdminRegistriesModule,
AdminRoutingModule
]
})
export class AdminModule {
}

View File

@@ -0,0 +1,13 @@
import { NgModule } from '@angular/core';
import { RouterModule } from '@angular/router';
import { LoginPageComponent } from './login-page.component';
@NgModule({
imports: [
RouterModule.forChild([
{ path: '', component: LoginPageComponent, data: { title: 'login.title' } }
])
]
})
export class LoginPageRoutingModule { }

View File

@@ -0,0 +1,9 @@
<div class="container w-100 h-100">
<div class="text-center mt-5 row justify-content-center">
<div>
<img class="mb-4 login-logo" src="assets/images/dspace-logo.png">
<h1 class="h3 mb-0 font-weight-normal">{{"login.form.header" | translate}}</h1>
<ds-log-in></ds-log-in>
</div>
</div>
</div>

View File

@@ -0,0 +1,6 @@
@import '../../styles/variables.scss';
.login-logo {
height: $login-logo-height;
width: $login-logo-width;
}

View File

@@ -0,0 +1,47 @@
import { NO_ERRORS_SCHEMA } from '@angular/core';
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { Store } from '@ngrx/store';
import { TranslateModule } from '@ngx-translate/core';
import { Observable } from 'rxjs/Observable';
import 'rxjs/add/observable/of';
import { LoginPageComponent } from './login-page.component';
describe('LoginPageComponent', () => {
let comp: LoginPageComponent;
let fixture: ComponentFixture<LoginPageComponent>;
const store: Store<LoginPageComponent> = jasmine.createSpyObj('store', {
/* tslint:disable:no-empty */
dispatch: {},
/* tslint:enable:no-empty */
select: Observable.of(true)
});
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [
TranslateModule.forRoot()
],
declarations: [LoginPageComponent],
providers: [
{
provide: Store, useValue: store
}
],
schemas: [NO_ERRORS_SCHEMA]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(LoginPageComponent);
comp = fixture.componentInstance; // SearchPageComponent test instance
fixture.detectChanges();
});
it('should create instance', () => {
expect(comp).toBeDefined()
});
});

View File

@@ -0,0 +1,21 @@
import { Component, OnDestroy } from '@angular/core';
import { Store } from '@ngrx/store';
import { AppState } from '../app.reducer';
import { ResetAuthenticationMessagesAction } from '../core/auth/auth.actions';
@Component({
selector: 'ds-login-page',
styleUrls: ['./login-page.component.scss'],
templateUrl: './login-page.component.html'
})
export class LoginPageComponent implements OnDestroy {
constructor(private store: Store<AppState>) {}
ngOnDestroy() {
// Clear all authentication messages when leaving login page
this.store.dispatch(new ResetAuthenticationMessagesAction());
}
}

View File

@@ -0,0 +1,19 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { SharedModule } from '../shared/shared.module';
import { LoginPageComponent } from './login-page.component';
import { LoginPageRoutingModule } from './login-page-routing.module';
@NgModule({
imports: [
LoginPageRoutingModule,
CommonModule,
SharedModule,
],
declarations: [
LoginPageComponent
]
})
export class LoginPageModule {
}

View File

@@ -0,0 +1,19 @@
import { NgModule } from '@angular/core';
import { RouterModule } from '@angular/router';
import { LogoutPageComponent } from './logout-page.component';
import { AuthenticatedGuard } from '../core/auth/authenticated.guard';
@NgModule({
imports: [
RouterModule.forChild([
{
canActivate: [AuthenticatedGuard],
path: '',
component: LogoutPageComponent,
data: { title: 'logout.title' }
}
])
]
})
export class LogoutPageRoutingModule { }

View File

@@ -0,0 +1,9 @@
<div class="container w-100 h-100">
<div class="text-center mt-5 row justify-content-md-center">
<div>
<img class="mb-4 login-logo" src="assets/images/dspace-logo.png">
<h1 class="h3 mb-0 font-weight-normal">{{"logout.form.header" | translate}}</h1>
<ds-log-out></ds-log-out>
</div>
</div>
</div>

View File

@@ -0,0 +1 @@
@import '../+login-page/login-page.component.scss';

View File

@@ -0,0 +1,31 @@
import { NO_ERRORS_SCHEMA } from '@angular/core';
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { TranslateModule } from '@ngx-translate/core';
import { LogoutPageComponent } from './logout-page.component';
describe('LogoutPageComponent', () => {
let comp: LogoutPageComponent;
let fixture: ComponentFixture<LogoutPageComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [
TranslateModule.forRoot()
],
declarations: [LogoutPageComponent],
schemas: [NO_ERRORS_SCHEMA]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(LogoutPageComponent);
comp = fixture.componentInstance; // SearchPageComponent test instance
fixture.detectChanges();
});
it('should create instance', () => {
expect(comp).toBeDefined()
});
});

View File

@@ -0,0 +1,10 @@
import { Component } from '@angular/core';
@Component({
selector: 'ds-logout-page',
styleUrls: ['./logout-page.component.scss'],
templateUrl: './logout-page.component.html'
})
export class LogoutPageComponent {
}

View File

@@ -0,0 +1,19 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { SharedModule } from '../shared/shared.module';
import { LogoutPageComponent } from './logout-page.component';
import { LogoutPageRoutingModule } from './logout-page-routing.module';
@NgModule({
imports: [
LogoutPageRoutingModule,
CommonModule,
SharedModule,
],
declarations: [
LogoutPageComponent
]
})
export class LogoutPageModule {
}

View File

@@ -14,7 +14,7 @@ import {
import { hasValue, isEmpty, isNotEmpty, } from '../../../shared/empty.util'; import { hasValue, isEmpty, isNotEmpty, } from '../../../shared/empty.util';
import { SearchFilterConfig } from '../../search-service/search-filter-config.model'; import { SearchFilterConfig } from '../../search-service/search-filter-config.model';
import { SearchService } from '../../search-service/search.service'; import { SearchService } from '../../search-service/search.service';
import { RouteService } from '../../../shared/route.service'; import { RouteService } from '../../../shared/services/route.service';
import ObjectExpression from 'rollup/dist/typings/ast/nodes/ObjectExpression'; import ObjectExpression from 'rollup/dist/typings/ast/nodes/ObjectExpression';
import { SortDirection, SortOptions } from '../../../core/cache/models/sort-options.model'; import { SortDirection, SortOptions } from '../../../core/cache/models/sort-options.model';
import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model'; import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model';

View File

@@ -1,6 +1,6 @@
import { isNotEmpty } from '../shared/empty.util'; import { isNotEmpty } from '../shared/empty.util';
import { URLCombiner } from '../core/url-combiner/url-combiner'; import { URLCombiner } from '../core/url-combiner/url-combiner';
import 'core-js/fn/object/entries'; import 'core-js/library/fn/object/entries';
export enum ViewMode { export enum ViewMode {
List = 'list', List = 'list',

View File

@@ -1,8 +1,8 @@
<div class="container"> <div class="container">
<div class="search-page row"> <div class="search-page row">
<ds-search-sidebar *ngIf="!(isMobileView$ | async)" class="col-3 sidebar-md-sticky" <ds-search-sidebar *ngIf="!(isXsOrSm$ | async)" class="col-3 sidebar-md-sticky"
id="search-sidebar" id="search-sidebar"
[resultCount]="(resultsRD$ | async)?.pageInfo?.totalElements"></ds-search-sidebar> [resultCount]="(resultsRD$ | async)?.pageInfo?.totalElements"></ds-search-sidebar>
<div class="col-12 col-md-9"> <div class="col-12 col-md-9">
<ds-search-form id="search-form" <ds-search-form id="search-form"
[query]="(searchOptions$ | async)?.query" [query]="(searchOptions$ | async)?.query"
@@ -14,7 +14,7 @@
<div id="search-body" <div id="search-body"
class="row-offcanvas row-offcanvas-left" class="row-offcanvas row-offcanvas-left"
[@pushInOut]="(isSidebarCollapsed() | async) ? 'collapsed' : 'expanded'"> [@pushInOut]="(isSidebarCollapsed() | async) ? 'collapsed' : 'expanded'">
<ds-search-sidebar *ngIf="(isMobileView$ | async)" class="col-12" <ds-search-sidebar *ngIf="(isXsOrSm$ | async)" class="col-12"
id="search-sidebar-sm" id="search-sidebar-sm"
[resultCount]="(resultsRD$ | async)?.pageInfo?.totalElements" [resultCount]="(resultsRD$ | async)?.pageInfo?.totalElements"
(toggleSidebar)="closeSidebar()" (toggleSidebar)="closeSidebar()"

View File

@@ -6,6 +6,7 @@ import { Store } from '@ngrx/store';
import { TranslateModule } from '@ngx-translate/core'; import { TranslateModule } from '@ngx-translate/core';
import { cold, hot } from 'jasmine-marbles'; import { cold, hot } from 'jasmine-marbles';
import { Observable } from 'rxjs/Observable'; import { Observable } from 'rxjs/Observable';
import 'rxjs/add/observable/of';
import { SortDirection, SortOptions } from '../core/cache/models/sort-options.model'; import { SortDirection, SortOptions } from '../core/cache/models/sort-options.model';
import { CommunityDataService } from '../core/data/community-data.service'; import { CommunityDataService } from '../core/data/community-data.service';
import { HostWindowService } from '../shared/host-window.service'; import { HostWindowService } from '../shared/host-window.service';
@@ -77,7 +78,8 @@ describe('SearchPageComponent', () => {
provide: HostWindowService, useValue: jasmine.createSpyObj('hostWindowService', provide: HostWindowService, useValue: jasmine.createSpyObj('hostWindowService',
{ {
isXs: Observable.of(true), isXs: Observable.of(true),
isSm: Observable.of(false) isSm: Observable.of(false),
isXsOrSm: Observable.of(true)
}) })
}, },
{ {

View File

@@ -34,7 +34,7 @@ export class SearchPageComponent implements OnInit {
searchOptions$: Observable<PaginatedSearchOptions>; searchOptions$: Observable<PaginatedSearchOptions>;
sortConfig: SortOptions; sortConfig: SortOptions;
scopeListRD$: Observable<RemoteData<PaginatedList<Community>>>; scopeListRD$: Observable<RemoteData<PaginatedList<Community>>>;
isMobileView$: Observable<boolean>; isXsOrSm$: Observable<boolean>;
pageSize; pageSize;
pageSizeOptions; pageSizeOptions;
defaults = { defaults = {
@@ -52,11 +52,7 @@ export class SearchPageComponent implements OnInit {
private sidebarService: SearchSidebarService, private sidebarService: SearchSidebarService,
private windowService: HostWindowService, private windowService: HostWindowService,
private filterService: SearchFilterService) { private filterService: SearchFilterService) {
this.isMobileView$ = Observable.combineLatest( this.isXsOrSm$ = this.windowService.isXsOrSm();
this.windowService.isXs(),
this.windowService.isSm(),
((isXs, isSm) => isXs || isSm)
);
this.scopeListRD$ = communityService.findAll(); this.scopeListRD$ = communityService.findAll();
} }

View File

@@ -7,7 +7,7 @@ import { Component } from '@angular/core';
import { SearchService } from './search.service'; import { SearchService } from './search.service';
import { ItemDataService } from './../../core/data/item-data.service'; import { ItemDataService } from './../../core/data/item-data.service';
import { ViewMode } from '../../+search-page/search-options.model'; import { ViewMode } from '../../+search-page/search-options.model';
import { RouteService } from '../../shared/route.service'; import { RouteService } from '../../shared/services/route.service';
import { GLOBAL_CONFIG } from '../../../config'; import { GLOBAL_CONFIG } from '../../../config';
import { RemoteDataBuildService } from '../../core/cache/builders/remote-data-build.service'; import { RemoteDataBuildService } from '../../core/cache/builders/remote-data-build.service';
import { ActivatedRoute, Router, UrlTree } from '@angular/router'; import { ActivatedRoute, Router, UrlTree } from '@angular/router';

View File

@@ -3,6 +3,7 @@ import { SearchSidebarService } from './search-sidebar.service';
import { AppState } from '../../app.reducer'; import { AppState } from '../../app.reducer';
import { async, inject, TestBed } from '@angular/core/testing'; import { async, inject, TestBed } from '@angular/core/testing';
import { Observable } from 'rxjs/Observable'; import { Observable } from 'rxjs/Observable';
import 'rxjs/add/observable/of';
import { SearchSidebarCollapseAction, SearchSidebarExpandAction } from './search-sidebar.actions'; import { SearchSidebarCollapseAction, SearchSidebarExpandAction } from './search-sidebar.actions';
import { HostWindowService } from '../../shared/host-window.service'; import { HostWindowService } from '../../shared/host-window.service';
@@ -17,7 +18,8 @@ describe('SearchSidebarService', () => {
const windowService = jasmine.createSpyObj('hostWindowService', const windowService = jasmine.createSpyObj('hostWindowService',
{ {
isXs: Observable.of(true), isXs: Observable.of(true),
isSm: Observable.of(false) isSm: Observable.of(false),
isXsOrSm: Observable.of(true)
}); });
beforeEach(async(() => { beforeEach(async(() => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({

View File

@@ -11,22 +11,17 @@ const sidebarCollapsedSelector = createSelector(sidebarStateSelector, (sidebar:
@Injectable() @Injectable()
export class SearchSidebarService { export class SearchSidebarService {
private isMobileView: Observable<boolean>; private isXsOrSm$: Observable<boolean>;
private isCollapsdeInStored: Observable<boolean>; private isCollapsdeInStored: Observable<boolean>;
constructor(private store: Store<AppState>, private windowService: HostWindowService) { constructor(private store: Store<AppState>, private windowService: HostWindowService) {
this.isMobileView = this.isXsOrSm$ = this.windowService.isXsOrSm();
Observable.combineLatest(
this.windowService.isXs(),
this.windowService.isSm(),
((isXs, isSm) => isXs || isSm)
);
this.isCollapsdeInStored = this.store.select(sidebarCollapsedSelector); this.isCollapsdeInStored = this.store.select(sidebarCollapsedSelector);
} }
get isCollapsed(): Observable<boolean> { get isCollapsed(): Observable<boolean> {
return Observable.combineLatest( return Observable.combineLatest(
this.isMobileView, this.isXsOrSm$,
this.isCollapsdeInStored, this.isCollapsdeInStored,
(mobile, store) => mobile ? store : true); (mobile, store) => mobile ? store : true);
} }

View File

@@ -12,6 +12,9 @@ import { PageNotFoundComponent } from './pagenotfound/pagenotfound.component';
{ path: 'collections', loadChildren: './+collection-page/collection-page.module#CollectionPageModule' }, { path: 'collections', loadChildren: './+collection-page/collection-page.module#CollectionPageModule' },
{ path: 'items', loadChildren: './+item-page/item-page.module#ItemPageModule' }, { path: 'items', loadChildren: './+item-page/item-page.module#ItemPageModule' },
{ path: 'search', loadChildren: './+search-page/search-page.module#SearchPageModule' }, { path: 'search', loadChildren: './+search-page/search-page.module#SearchPageModule' },
{ path: 'admin', loadChildren: './+admin/admin.module#AdminModule' },
{ path: 'login', loadChildren: './+login-page/login-page.module#LoginPageModule' },
{ path: 'logout', loadChildren: './+logout-page/logout-page.module#LogoutPageModule' },
{ path: '**', pathMatch: 'full', component: PageNotFoundComponent }, { path: '**', pathMatch: 'full', component: PageNotFoundComponent },
]) ])
], ],

View File

@@ -26,12 +26,14 @@ import { HostWindowResizeAction } from './shared/host-window.actions';
import { MetadataService } from './core/metadata/metadata.service'; import { MetadataService } from './core/metadata/metadata.service';
import { GLOBAL_CONFIG, ENV_CONFIG } from '../config'; import { GLOBAL_CONFIG, ENV_CONFIG } from '../config';
import { NativeWindowRef, NativeWindowService } from './shared/window.service'; import { NativeWindowRef, NativeWindowService } from './shared/services/window.service';
import { MockTranslateLoader } from './shared/mocks/mock-translate-loader'; import { MockTranslateLoader } from './shared/mocks/mock-translate-loader';
import { MockMetadataService } from './shared/mocks/mock-metadata-service'; import { MockMetadataService } from './shared/mocks/mock-metadata-service';
import { Angulartics2GoogleAnalytics } from 'angulartics2/ga'; import { Angulartics2GoogleAnalytics } from 'angulartics2/ga';
import { AngularticsMock } from './shared/mocks/mock-angulartics.service'; import { AngularticsMock } from './shared/mocks/mock-angulartics.service';
import { AuthServiceMock } from './shared/mocks/mock-auth.service';
import { AuthService } from './core/auth/auth.service';
let comp: AppComponent; let comp: AppComponent;
let fixture: ComponentFixture<AppComponent>; let fixture: ComponentFixture<AppComponent>;
@@ -59,6 +61,7 @@ describe('App component', () => {
{ provide: NativeWindowService, useValue: new NativeWindowRef() }, { provide: NativeWindowService, useValue: new NativeWindowRef() },
{ provide: MetadataService, useValue: new MockMetadataService() }, { provide: MetadataService, useValue: new MockMetadataService() },
{ provide: Angulartics2GoogleAnalytics, useValue: new AngularticsMock() }, { provide: Angulartics2GoogleAnalytics, useValue: new AngularticsMock() },
{ provide: AuthService, useValue: new AuthServiceMock() },
AppComponent AppComponent
], ],
schemas: [CUSTOM_ELEMENTS_SCHEMA] schemas: [CUSTOM_ELEMENTS_SCHEMA]

View File

@@ -9,7 +9,9 @@ import { GLOBAL_CONFIG, GlobalConfig } from '../config';
import { MetadataService } from './core/metadata/metadata.service'; import { MetadataService } from './core/metadata/metadata.service';
import { HostWindowResizeAction } from './shared/host-window.actions'; import { HostWindowResizeAction } from './shared/host-window.actions';
import { HostWindowState } from './shared/host-window.reducer'; import { HostWindowState } from './shared/host-window.reducer';
import { NativeWindowRef, NativeWindowService } from './shared/window.service'; import { NativeWindowRef, NativeWindowService } from './shared/services/window.service';
import { isAuthenticated } from './core/auth/selectors';
import { AuthService } from './core/auth/auth.service';
import { Angulartics2GoogleAnalytics } from 'angulartics2/ga'; import { Angulartics2GoogleAnalytics } from 'angulartics2/ga';
@Component({ @Component({
@@ -27,7 +29,8 @@ export class AppComponent implements OnInit {
private translate: TranslateService, private translate: TranslateService,
private store: Store<HostWindowState>, private store: Store<HostWindowState>,
private metadata: MetadataService, private metadata: MetadataService,
private angulartics2GoogleAnalytics: Angulartics2GoogleAnalytics private angulartics2GoogleAnalytics: Angulartics2GoogleAnalytics,
private authService: AuthService
) { ) {
// this language will be used as a fallback when a translation isn't found in the current language // this language will be used as a fallback when a translation isn't found in the current language
translate.setDefaultLang('en'); translate.setDefaultLang('en');
@@ -46,6 +49,13 @@ export class AppComponent implements OnInit {
const color: string = this.config.production ? 'red' : 'green'; const color: string = this.config.production ? 'red' : 'green';
console.info(`Environment: %c${env}`, `color: ${color}; font-weight: bold;`); console.info(`Environment: %c${env}`, `color: ${color}; font-weight: bold;`);
this.dispatchWindowSize(this._window.nativeWindow.innerWidth, this._window.nativeWindow.innerHeight); this.dispatchWindowSize(this._window.nativeWindow.innerWidth, this._window.nativeWindow.innerHeight);
// Whether is not authenticathed try to retrieve a possible stored auth token
this.store.select(isAuthenticated)
.take(1)
.filter((authenticated) => !authenticated)
.subscribe((authenticated) => this.authService.checkAuthenticationToken());
} }
@HostListener('window:resize', ['$event']) @HostListener('window:resize', ['$event'])

View File

@@ -30,6 +30,7 @@ import { PageNotFoundComponent } from './pagenotfound/pagenotfound.component';
import { DSpaceRouterStateSerializer } from './shared/ngrx/dspace-router-state-serializer'; import { DSpaceRouterStateSerializer } from './shared/ngrx/dspace-router-state-serializer';
import { NotificationsBoardComponent } from './shared/notifications/notifications-board/notifications-board.component'; import { NotificationsBoardComponent } from './shared/notifications/notifications-board/notifications-board.component';
import { NotificationComponent } from './shared/notifications/notification/notification.component'; import { NotificationComponent } from './shared/notifications/notification/notification.component';
import { SharedModule } from './shared/shared.module';
export function getConfig() { export function getConfig() {
return ENV_CONFIG; return ENV_CONFIG;
@@ -53,6 +54,7 @@ if (!ENV_CONFIG.production) {
@NgModule({ @NgModule({
imports: [ imports: [
CommonModule, CommonModule,
SharedModule,
HttpClientModule, HttpClientModule,
AppRoutingModule, AppRoutingModule,
CoreModule.forRoot(), CoreModule.forRoot(),

View File

@@ -36,3 +36,5 @@ export const appReducers: ActionReducerMap<AppState> = {
searchFilter: filterReducer, searchFilter: filterReducer,
truncatable: truncatableReducer truncatable: truncatableReducer
}; };
export const routerStateSelector = (state: AppState) => state.router;

View File

@@ -0,0 +1,23 @@
import { AuthType } from './auth-type';
import { GenericConstructor } from '../shared/generic-constructor';
import { NormalizedAuthStatus } from './models/normalized-auth-status.model';
import { NormalizedDSpaceObject } from '../cache/models/normalized-dspace-object.model';
import { NormalizedEpersonModel } from '../eperson/models/NormalizedEperson.model';
export class AuthObjectFactory {
public static getConstructor(type): GenericConstructor<NormalizedDSpaceObject> {
switch (type) {
case AuthType.Eperson: {
return NormalizedEpersonModel
}
case AuthType.Status: {
return NormalizedAuthStatus
}
default: {
return undefined;
}
}
}
}

View File

@@ -0,0 +1,65 @@
import { Inject, Injectable } from '@angular/core';
import { HALEndpointService } from '../shared/hal-endpoint.service';
import { ResponseCacheService } from '../cache/response-cache.service';
import { RequestService } from '../data/request.service';
import { GLOBAL_CONFIG } from '../../../config';
import { GlobalConfig } from '../../../config/global-config.interface';
import { Observable } from 'rxjs/Observable';
import { isNotEmpty } from '../../shared/empty.util';
import { AuthGetRequest, AuthPostRequest, PostRequest, RestRequest } from '../data/request.models';
import { ResponseCacheEntry } from '../cache/response-cache.reducer';
import { AuthStatusResponse, ErrorResponse, RestResponse } from '../cache/response-cache.models';
import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service';
@Injectable()
export class AuthRequestService {
protected linkName = 'authn';
protected browseEndpoint = '';
constructor(@Inject(GLOBAL_CONFIG) protected EnvConfig: GlobalConfig,
protected halService: HALEndpointService,
protected responseCache: ResponseCacheService,
protected requestService: RequestService) {
}
protected fetchRequest(request: RestRequest): Observable<any> {
const [successResponse, errorResponse] = this.responseCache.get(request.href)
.map((entry: ResponseCacheEntry) => entry.response)
// TODO to review when https://github.com/DSpace/dspace-angular/issues/217 will be fixed
.do(() => this.responseCache.remove(request.href))
.partition((response: RestResponse) => response.isSuccessful);
return Observable.merge(
errorResponse.flatMap((response: ErrorResponse) =>
Observable.throw(new Error(response.errorMessage))),
successResponse
.filter((response: AuthStatusResponse) => isNotEmpty(response))
.map((response: AuthStatusResponse) => response.response)
.distinctUntilChanged());
}
protected getEndpointByMethod(endpoint: string, method: string): string {
return isNotEmpty(method) ? `${endpoint}/${method}` : `${endpoint}`;
}
public postToEndpoint(method: string, body: any, options?: HttpOptions): Observable<any> {
return this.halService.getEndpoint(this.linkName)
.filter((href: string) => isNotEmpty(href))
.map((endpointURL) => this.getEndpointByMethod(endpointURL, method))
.distinctUntilChanged()
.map((endpointURL: string) => new AuthPostRequest(this.requestService.generateRequestId(), endpointURL, body, options))
.do((request: PostRequest) => this.requestService.configure(request, true))
.flatMap((request: PostRequest) => this.fetchRequest(request))
.distinctUntilChanged();
}
public getRequest(method: string, options?: HttpOptions): Observable<any> {
return this.halService.getEndpoint(this.linkName)
.filter((href: string) => isNotEmpty(href))
.map((endpointURL) => this.getEndpointByMethod(endpointURL, method))
.distinctUntilChanged()
.map((endpointURL: string) => new AuthGetRequest(this.requestService.generateRequestId(), endpointURL, options))
.do((request: PostRequest) => this.requestService.configure(request, true))
.flatMap((request: PostRequest) => this.fetchRequest(request))
.distinctUntilChanged();
}
}

View File

@@ -0,0 +1,117 @@
import { AuthStatusResponse } from '../cache/response-cache.models';
import { ObjectCacheService } from '../cache/object-cache.service';
import { GlobalConfig } from '../../../config/global-config.interface';
import { Store } from '@ngrx/store';
import { CoreState } from '../core.reducers';
import { AuthStatus } from './models/auth-status.model';
import { AuthResponseParsingService } from './auth-response-parsing.service';
import { AuthGetRequest, AuthPostRequest } from '../data/request.models';
describe('ConfigResponseParsingService', () => {
let service: AuthResponseParsingService;
const EnvConfig = {} as GlobalConfig;
const store = {} as Store<CoreState>;
const objectCacheService = new ObjectCacheService(store);
beforeEach(() => {
service = new AuthResponseParsingService(EnvConfig, objectCacheService);
});
describe('parse', () => {
const validRequest = new AuthPostRequest(
'69f375b5-19f4-4453-8c7a-7dc5c55aafbb',
'https://rest.api/dspace-spring-rest/api/authn/login',
'password=test&user=myself@testshib.org');
const validRequest2 = new AuthGetRequest(
'69f375b5-19f4-4453-8c7a-7dc5c55aafbb',
'https://rest.api/dspace-spring-rest/api/authn/status');
const validResponse = {
payload: {
authenticated: true,
id: null,
okay: true,
token: {
accessToken: 'eyJhbGciOiJIUzI1NiJ9.eyJlaWQiOiI0ZGM3MGFiNS1jZDczLTQ5MmYtYjAwNy0zMTc5ZDJkOTI5NmIiLCJzZyI6W10sImV4cCI6MTUyNjMxODMyMn0.ASmvcbJFBfzhN7D5ncloWnaVZr5dLtgTuOgHaCKiimc',
expires: 1526318322000
},
} as AuthStatus,
statusCode: '200'
};
const validResponse1 = {
payload: {},
statusCode: '404'
};
const validResponse2 = {
payload: {
authenticated: true,
id: null,
okay: true,
type: 'status',
_embedded: {
eperson: {
canLogIn: true,
email: 'myself@testshib.org',
groups: [],
handle: null,
id: '4dc70ab5-cd73-492f-b007-3179d2d9296b',
lastActive: '2018-05-14T17:03:31.277+0000',
metadata: [
{
key: 'eperson.firstname',
language: null,
value: 'User'
},
{
key: 'eperson.lastname',
language: null,
value: 'Test'
},
{
key: 'eperson.language',
language: null,
value: 'en'
}
],
name: 'User Test',
netid: 'myself@testshib.org',
requireCertificate: false,
selfRegistered: false,
type: 'eperson',
uuid: '4dc70ab5-cd73-492f-b007-3179d2d9296b',
_links: {
self: 'https://hasselt-dspace.dev01.4science.it/dspace-spring-rest/api/eperson/epersons/4dc70ab5-cd73-492f-b007-3179d2d9296b'
}
}
},
_links: {
eperson: 'https://hasselt-dspace.dev01.4science.it/dspace-spring-rest/api/eperson/epersons/4dc70ab5-cd73-492f-b007-3179d2d9296b',
self: 'https://hasselt-dspace.dev01.4science.it/dspace-spring-rest/api/authn/status'
}
},
statusCode: '200'
};
it('should return a AuthStatusResponse if data contains a valid AuthStatus object as payload', () => {
const response = service.parse(validRequest, validResponse);
expect(response.constructor).toBe(AuthStatusResponse);
});
it('should return a AuthStatusResponse if data contains a valid endpoint response', () => {
const response = service.parse(validRequest2, validResponse2);
expect(response.constructor).toBe(AuthStatusResponse);
});
it('should return a AuthStatusResponse if data contains an empty 404 endpoint response', () => {
const response = service.parse(validRequest, validResponse1);
expect(response.constructor).toBe(AuthStatusResponse);
});
});
});

View File

@@ -0,0 +1,36 @@
import { Inject, Injectable } from '@angular/core';
import { AuthObjectFactory } from './auth-object-factory';
import { BaseResponseParsingService } from '../data/base-response-parsing.service';
import { AuthStatusResponse, RestResponse } from '../cache/response-cache.models';
import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model';
import { GLOBAL_CONFIG } from '../../../config';
import { GlobalConfig } from '../../../config/global-config.interface';
import { isNotEmpty } from '../../shared/empty.util';
import { ObjectCacheService } from '../cache/object-cache.service';
import { ResponseParsingService } from '../data/parsing.service';
import { RestRequest } from '../data/request.models';
import { AuthType } from './auth-type';
import { AuthStatus } from './models/auth-status.model';
@Injectable()
export class AuthResponseParsingService extends BaseResponseParsingService implements ResponseParsingService {
protected objectFactory = AuthObjectFactory;
protected toCache = false;
constructor(@Inject(GLOBAL_CONFIG) protected EnvConfig: GlobalConfig,
protected objectCache: ObjectCacheService,) {
super();
}
parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse {
if (isNotEmpty(data.payload) && isNotEmpty(data.payload._links) && (data.statusCode === '200' || data.statusCode === 'OK')) {
const response = this.process<AuthStatus, AuthType>(data.payload, request.href);
return new AuthStatusResponse(response[Object.keys(response)[0]][0], data.statusCode);
} else {
return new AuthStatusResponse(data.payload as AuthStatus, data.statusCode);
}
}
}

View File

@@ -0,0 +1,4 @@
export enum AuthType {
Eperson = 'eperson',
Status = 'status'
}

View File

@@ -0,0 +1,346 @@
// import @ngrx
import { Action } from '@ngrx/store';
// import type function
import { type } from '../../shared/ngrx/type';
// import models
import { Eperson } from '../eperson/models/eperson.model';
import { AuthTokenInfo } from './models/auth-token-info.model';
export const AuthActionTypes = {
AUTHENTICATE: type('dspace/auth/AUTHENTICATE'),
AUTHENTICATE_ERROR: type('dspace/auth/AUTHENTICATE_ERROR'),
AUTHENTICATE_SUCCESS: type('dspace/auth/AUTHENTICATE_SUCCESS'),
AUTHENTICATED: type('dspace/auth/AUTHENTICATED'),
AUTHENTICATED_ERROR: type('dspace/auth/AUTHENTICATED_ERROR'),
AUTHENTICATED_SUCCESS: type('dspace/auth/AUTHENTICATED_SUCCESS'),
CHECK_AUTHENTICATION_TOKEN: type('dspace/auth/CHECK_AUTHENTICATION_TOKEN'),
CHECK_AUTHENTICATION_TOKEN_ERROR: type('dspace/auth/CHECK_AUTHENTICATION_TOKEN_ERROR'),
REDIRECT_TOKEN_EXPIRED: type('dspace/auth/REDIRECT_TOKEN_EXPIRED'),
REDIRECT_AUTHENTICATION_REQUIRED: type('dspace/auth/REDIRECT_AUTHENTICATION_REQUIRED'),
REFRESH_TOKEN: type('dspace/auth/REFRESH_TOKEN'),
REFRESH_TOKEN_SUCCESS: type('dspace/auth/REFRESH_TOKEN_SUCCESS'),
REFRESH_TOKEN_ERROR: type('dspace/auth/REFRESH_TOKEN_ERROR'),
ADD_MESSAGE: type('dspace/auth/ADD_MESSAGE'),
RESET_MESSAGES: type('dspace/auth/RESET_MESSAGES'),
LOG_OUT: type('dspace/auth/LOG_OUT'),
LOG_OUT_ERROR: type('dspace/auth/LOG_OUT_ERROR'),
LOG_OUT_SUCCESS: type('dspace/auth/LOG_OUT_SUCCESS'),
REGISTRATION: type('dspace/auth/REGISTRATION'),
REGISTRATION_ERROR: type('dspace/auth/REGISTRATION_ERROR'),
REGISTRATION_SUCCESS: type('dspace/auth/REGISTRATION_SUCCESS'),
SET_REDIRECT_URL: type('dspace/auth/SET_REDIRECT_URL'),
};
/* tslint:disable:max-classes-per-file */
/**
* Authenticate.
* @class AuthenticateAction
* @implements {Action}
*/
export class AuthenticateAction implements Action {
public type: string = AuthActionTypes.AUTHENTICATE;
payload: {
email: string;
password: string
};
constructor(email: string, password: string) {
this.payload = { email, password };
}
}
/**
* Checks if user is authenticated.
* @class AuthenticatedAction
* @implements {Action}
*/
export class AuthenticatedAction implements Action {
public type: string = AuthActionTypes.AUTHENTICATED;
payload: AuthTokenInfo;
constructor(token: AuthTokenInfo) {
this.payload = token;
}
}
/**
* Authenticated check success.
* @class AuthenticatedSuccessAction
* @implements {Action}
*/
export class AuthenticatedSuccessAction implements Action {
public type: string = AuthActionTypes.AUTHENTICATED_SUCCESS;
payload: {
authenticated: boolean;
authToken: AuthTokenInfo;
user: Eperson
};
constructor(authenticated: boolean, authToken: AuthTokenInfo, user: Eperson) {
this.payload = { authenticated, authToken, user };
}
}
/**
* Authenticated check error.
* @class AuthenticatedErrorAction
* @implements {Action}
*/
export class AuthenticatedErrorAction implements Action {
public type: string = AuthActionTypes.AUTHENTICATED_ERROR;
payload: Error;
constructor(payload: Error) {
this.payload = payload ;
}
}
/**
* Authentication error.
* @class AuthenticationErrorAction
* @implements {Action}
*/
export class AuthenticationErrorAction implements Action {
public type: string = AuthActionTypes.AUTHENTICATE_ERROR;
payload: Error;
constructor(payload: Error) {
this.payload = payload ;
}
}
/**
* Authentication success.
* @class AuthenticationSuccessAction
* @implements {Action}
*/
export class AuthenticationSuccessAction implements Action {
public type: string = AuthActionTypes.AUTHENTICATE_SUCCESS;
payload: AuthTokenInfo;
constructor(token: AuthTokenInfo) {
this.payload = token;
}
}
/**
* Check if token is already present upon initial load.
* @class CheckAuthenticationTokenAction
* @implements {Action}
*/
export class CheckAuthenticationTokenAction implements Action {
public type: string = AuthActionTypes.CHECK_AUTHENTICATION_TOKEN;
}
/**
* Check Authentication Token Error.
* @class CheckAuthenticationTokenErrorAction
* @implements {Action}
*/
export class CheckAuthenticationTokenErrorAction implements Action {
public type: string = AuthActionTypes.CHECK_AUTHENTICATION_TOKEN_ERROR;
}
/**
* Sign out.
* @class LogOutAction
* @implements {Action}
*/
export class LogOutAction implements Action {
public type: string = AuthActionTypes.LOG_OUT;
constructor(public payload?: any) {}
}
/**
* Sign out error.
* @class LogOutErrorAction
* @implements {Action}
*/
export class LogOutErrorAction implements Action {
public type: string = AuthActionTypes.LOG_OUT_ERROR;
payload: Error;
constructor(payload: Error) {
this.payload = payload ;
}
}
/**
* Sign out success.
* @class LogOutSuccessAction
* @implements {Action}
*/
export class LogOutSuccessAction implements Action {
public type: string = AuthActionTypes.LOG_OUT_SUCCESS;
constructor(public payload?: any) {}
}
/**
* Redirect to login page when authentication is required.
* @class RedirectWhenAuthenticationIsRequiredAction
* @implements {Action}
*/
export class RedirectWhenAuthenticationIsRequiredAction implements Action {
public type: string = AuthActionTypes.REDIRECT_AUTHENTICATION_REQUIRED;
payload: string;
constructor(message: string) {
this.payload = message ;
}
}
/**
* Redirect to login page when token is expired.
* @class RedirectWhenTokenExpiredAction
* @implements {Action}
*/
export class RedirectWhenTokenExpiredAction implements Action {
public type: string = AuthActionTypes.REDIRECT_TOKEN_EXPIRED;
payload: string;
constructor(message: string) {
this.payload = message ;
}
}
/**
* Refresh authentication token.
* @class RefreshTokenAction
* @implements {Action}
*/
export class RefreshTokenAction implements Action {
public type: string = AuthActionTypes.REFRESH_TOKEN;
payload: AuthTokenInfo;
constructor(token: AuthTokenInfo) {
this.payload = token;
}
}
/**
* Refresh authentication token success.
* @class RefreshTokenSuccessAction
* @implements {Action}
*/
export class RefreshTokenSuccessAction implements Action {
public type: string = AuthActionTypes.REFRESH_TOKEN_SUCCESS;
payload: AuthTokenInfo;
constructor(token: AuthTokenInfo) {
this.payload = token;
}
}
/**
* Refresh authentication token error.
* @class RefreshTokenErrorAction
* @implements {Action}
*/
export class RefreshTokenErrorAction implements Action {
public type: string = AuthActionTypes.REFRESH_TOKEN_ERROR;
}
/**
* Sign up.
* @class RegistrationAction
* @implements {Action}
*/
export class RegistrationAction implements Action {
public type: string = AuthActionTypes.REGISTRATION;
payload: Eperson;
constructor(user: Eperson) {
this.payload = user;
}
}
/**
* Sign up error.
* @class RegistrationErrorAction
* @implements {Action}
*/
export class RegistrationErrorAction implements Action {
public type: string = AuthActionTypes.REGISTRATION_ERROR;
payload: Error;
constructor(payload: Error) {
this.payload = payload ;
}
}
/**
* Sign up success.
* @class RegistrationSuccessAction
* @implements {Action}
*/
export class RegistrationSuccessAction implements Action {
public type: string = AuthActionTypes.REGISTRATION_SUCCESS;
payload: Eperson;
constructor(user: Eperson) {
this.payload = user;
}
}
/**
* Add uthentication message.
* @class AddAuthenticationMessageAction
* @implements {Action}
*/
export class AddAuthenticationMessageAction implements Action {
public type: string = AuthActionTypes.ADD_MESSAGE;
payload: string;
constructor(message: string) {
this.payload = message;
}
}
/**
* Reset error.
* @class ResetAuthenticationMessagesAction
* @implements {Action}
*/
export class ResetAuthenticationMessagesAction implements Action {
public type: string = AuthActionTypes.RESET_MESSAGES;
}
/**
* Change the redirect url.
* @class SetRedirectUrlAction
* @implements {Action}
*/
export class SetRedirectUrlAction implements Action {
public type: string = AuthActionTypes.SET_REDIRECT_URL;
payload: string;
constructor(url: string) {
this.payload = url ;
}
}
/* tslint:enable:max-classes-per-file */
/**
* Actions type.
* @type {AuthActions}
*/
export type AuthActions
= AuthenticateAction
| AuthenticatedAction
| AuthenticatedErrorAction
| AuthenticatedSuccessAction
| AuthenticationErrorAction
| AuthenticationSuccessAction
| CheckAuthenticationTokenAction
| CheckAuthenticationTokenErrorAction
| RedirectWhenAuthenticationIsRequiredAction
| RedirectWhenTokenExpiredAction
| RegistrationAction
| RegistrationErrorAction
| RegistrationSuccessAction
| AddAuthenticationMessageAction
| ResetAuthenticationMessagesAction;

View File

@@ -0,0 +1,204 @@
import { TestBed } from '@angular/core/testing';
import { provideMockActions } from '@ngrx/effects/testing';
import { Store } from '@ngrx/store';
import { cold, hot } from 'jasmine-marbles';
import { Observable } from 'rxjs/Observable';
import 'rxjs/add/observable/of'
import { AuthEffects } from './auth.effects';
import {
AuthActionTypes,
AuthenticatedAction,
AuthenticatedErrorAction,
AuthenticatedSuccessAction,
AuthenticationErrorAction,
AuthenticationSuccessAction,
CheckAuthenticationTokenErrorAction,
LogOutErrorAction,
LogOutSuccessAction,
RefreshTokenErrorAction,
RefreshTokenSuccessAction
} from './auth.actions';
import { AuthServiceStub } from '../../shared/testing/auth-service-stub';
import { AuthService } from './auth.service';
import { TruncatablesState } from '../../shared/truncatable/truncatable.reducer';
import { EpersonMock } from '../../shared/testing/eperson-mock';
describe('AuthEffects', () => {
let authEffects: AuthEffects;
let actions: Observable<any>;
const authServiceStub = new AuthServiceStub();
const store: Store<TruncatablesState> = jasmine.createSpyObj('store', {
/* tslint:disable:no-empty */
dispatch: {},
/* tslint:enable:no-empty */
select: Observable.of(true)
});
const token = authServiceStub.getToken();
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
AuthEffects,
{provide: AuthService, useValue: authServiceStub},
{provide: Store, useValue: store},
provideMockActions(() => actions),
// other providers
],
});
authEffects = TestBed.get(AuthEffects);
});
describe('authenticate$', () => {
describe('when credentials are correct', () => {
it('should return a AUTHENTICATE_SUCCESS action in response to a AUTHENTICATE action', () => {
actions = hot('--a-', {
a: {
type: AuthActionTypes.AUTHENTICATE,
payload: {email: 'user', password: 'password'}
}
});
const expected = cold('--b-', {b: new AuthenticationSuccessAction(token)});
expect(authEffects.authenticate$).toBeObservable(expected);
});
});
describe('when credentials are wrong', () => {
it('should return a AUTHENTICATE_ERROR action in response to a AUTHENTICATE action', () => {
spyOn((authEffects as any).authService, 'authenticate').and.returnValue(Observable.throw(new Error('Message Error test')));
actions = hot('--a-', {
a: {
type: AuthActionTypes.AUTHENTICATE,
payload: {email: 'user', password: 'wrongpassword'}
}
});
const expected = cold('--b-', {b: new AuthenticationErrorAction(new Error('Message Error test'))});
expect(authEffects.authenticate$).toBeObservable(expected);
});
});
});
describe('authenticateSuccess$', () => {
it('should return a AUTHENTICATED action in response to a AUTHENTICATE_SUCCESS action', () => {
actions = hot('--a-', {a: {type: AuthActionTypes.AUTHENTICATE_SUCCESS, payload: token}});
const expected = cold('--b-', {b: new AuthenticatedAction(token)});
expect(authEffects.authenticateSuccess$).toBeObservable(expected);
});
});
describe('authenticated$', () => {
describe('when token is valid', () => {
it('should return a AUTHENTICATED_SUCCESS action in response to a AUTHENTICATED action', () => {
actions = hot('--a-', {a: {type: AuthActionTypes.AUTHENTICATED, payload: token}});
const expected = cold('--b-', {b: new AuthenticatedSuccessAction(true, token, EpersonMock)});
expect(authEffects.authenticated$).toBeObservable(expected);
});
});
describe('when token is not valid', () => {
it('should return a AUTHENTICATED_ERROR action in response to a AUTHENTICATED action', () => {
spyOn((authEffects as any).authService, 'authenticatedUser').and.returnValue(Observable.throw(new Error('Message Error test')));
actions = hot('--a-', {a: {type: AuthActionTypes.AUTHENTICATED, payload: token}});
const expected = cold('--b-', {b: new AuthenticatedErrorAction(new Error('Message Error test'))});
expect(authEffects.authenticated$).toBeObservable(expected);
});
});
});
describe('checkToken$', () => {
describe('when check token succeeded', () => {
it('should return a AUTHENTICATED action in response to a CHECK_AUTHENTICATION_TOKEN action', () => {
actions = hot('--a-', {a: {type: AuthActionTypes.CHECK_AUTHENTICATION_TOKEN}});
const expected = cold('--b-', {b: new AuthenticatedAction(token)});
expect(authEffects.checkToken$).toBeObservable(expected);
});
});
describe('when check token failed', () => {
it('should return a CHECK_AUTHENTICATION_TOKEN_ERROR action in response to a CHECK_AUTHENTICATION_TOKEN action', () => {
spyOn((authEffects as any).authService, 'hasValidAuthenticationToken').and.returnValue(Observable.throw(''));
actions = hot('--a-', {a: {type: AuthActionTypes.CHECK_AUTHENTICATION_TOKEN, payload: token}});
const expected = cold('--b-', {b: new CheckAuthenticationTokenErrorAction()});
expect(authEffects.checkToken$).toBeObservable(expected);
});
})
});
describe('refreshToken$', () => {
describe('when refresh token succeeded', () => {
it('should return a REFRESH_TOKEN_SUCCESS action in response to a REFRESH_TOKEN action', () => {
actions = hot('--a-', {a: {type: AuthActionTypes.REFRESH_TOKEN}});
const expected = cold('--b-', {b: new RefreshTokenSuccessAction(token)});
expect(authEffects.refreshToken$).toBeObservable(expected);
});
});
describe('when refresh token failed', () => {
it('should return a REFRESH_TOKEN_ERROR action in response to a REFRESH_TOKEN action', () => {
spyOn((authEffects as any).authService, 'refreshAuthenticationToken').and.returnValue(Observable.throw(''));
actions = hot('--a-', {a: {type: AuthActionTypes.REFRESH_TOKEN, payload: token}});
const expected = cold('--b-', {b: new RefreshTokenErrorAction()});
expect(authEffects.refreshToken$).toBeObservable(expected);
});
})
});
describe('logOut$', () => {
describe('when refresh token succeeded', () => {
it('should return a LOG_OUT_SUCCESS action in response to a LOG_OUT action', () => {
actions = hot('--a-', {a: {type: AuthActionTypes.LOG_OUT}});
const expected = cold('--b-', {b: new LogOutSuccessAction()});
expect(authEffects.logOut$).toBeObservable(expected);
});
});
describe('when refresh token failed', () => {
it('should return a REFRESH_TOKEN_ERROR action in response to a LOG_OUT action', () => {
spyOn((authEffects as any).authService, 'logout').and.returnValue(Observable.throw(new Error('Message Error test')));
actions = hot('--a-', {a: {type: AuthActionTypes.LOG_OUT, payload: token}});
const expected = cold('--b-', {b: new LogOutErrorAction(new Error('Message Error test'))});
expect(authEffects.logOut$).toBeObservable(expected);
});
})
});
});

View File

@@ -0,0 +1,163 @@
import { Injectable } from '@angular/core';
// import @ngrx
import { Actions, Effect } from '@ngrx/effects';
import { Action, Store } from '@ngrx/store';
// import rxjs
import { Observable } from 'rxjs/Observable';
// import services
import { AuthService } from './auth.service';
// import actions
import {
AuthActionTypes,
AuthenticateAction,
AuthenticatedAction,
AuthenticatedErrorAction,
AuthenticatedSuccessAction,
AuthenticationErrorAction,
AuthenticationSuccessAction,
CheckAuthenticationTokenErrorAction,
LogOutErrorAction,
LogOutSuccessAction,
RefreshTokenAction,
RefreshTokenErrorAction,
RefreshTokenSuccessAction,
RegistrationAction,
RegistrationErrorAction,
RegistrationSuccessAction
} from './auth.actions';
import { Eperson } from '../eperson/models/eperson.model';
import { AuthStatus } from './models/auth-status.model';
import { AuthTokenInfo } from './models/auth-token-info.model';
import { AppState } from '../../app.reducer';
import { isAuthenticated } from './selectors';
import { StoreActionTypes } from '../../store.actions';
@Injectable()
export class AuthEffects {
/**
* Authenticate user.
* @method authenticate
*/
@Effect()
public authenticate$: Observable<Action> = this.actions$
.ofType(AuthActionTypes.AUTHENTICATE)
.switchMap((action: AuthenticateAction) => {
return this.authService.authenticate(action.payload.email, action.payload.password)
.first()
.map((response: AuthStatus) => new AuthenticationSuccessAction(response.token))
.catch((error) => Observable.of(new AuthenticationErrorAction(error)));
});
@Effect()
public authenticateSuccess$: Observable<Action> = this.actions$
.ofType(AuthActionTypes.AUTHENTICATE_SUCCESS)
.do((action: AuthenticationSuccessAction) => this.authService.storeToken(action.payload))
.map((action: AuthenticationSuccessAction) => new AuthenticatedAction(action.payload));
@Effect()
public authenticated$: Observable<Action> = this.actions$
.ofType(AuthActionTypes.AUTHENTICATED)
.switchMap((action: AuthenticatedAction) => {
return this.authService.authenticatedUser(action.payload)
.map((user: Eperson) => new AuthenticatedSuccessAction((user !== null), action.payload, user))
.catch((error) => Observable.of(new AuthenticatedErrorAction(error)));
});
// It means "reacts to this action but don't send another"
@Effect({dispatch: false})
public authenticatedError$: Observable<Action> = this.actions$
.ofType(AuthActionTypes.AUTHENTICATED_ERROR)
.do((action: LogOutSuccessAction) => this.authService.removeToken());
@Effect()
public checkToken$: Observable<Action> = this.actions$
.ofType(AuthActionTypes.CHECK_AUTHENTICATION_TOKEN)
.switchMap(() => {
return this.authService.hasValidAuthenticationToken()
.map((token: AuthTokenInfo) => new AuthenticatedAction(token))
.catch((error) => Observable.of(new CheckAuthenticationTokenErrorAction()));
});
@Effect()
public createUser$: Observable<Action> = this.actions$
.ofType(AuthActionTypes.REGISTRATION)
.debounceTime(500) // to remove when functionality is implemented
.switchMap((action: RegistrationAction) => {
return this.authService.create(action.payload)
.map((user: Eperson) => new RegistrationSuccessAction(user))
.catch((error) => Observable.of(new RegistrationErrorAction(error)));
});
@Effect()
public refreshToken$: Observable<Action> = this.actions$
.ofType(AuthActionTypes.REFRESH_TOKEN)
.switchMap((action: RefreshTokenAction) => {
return this.authService.refreshAuthenticationToken(action.payload)
.map((token: AuthTokenInfo) => new RefreshTokenSuccessAction(token))
.catch((error) => Observable.of(new RefreshTokenErrorAction()));
});
// It means "reacts to this action but don't send another"
@Effect({dispatch: false})
public refreshTokenSuccess$: Observable<Action> = this.actions$
.ofType(AuthActionTypes.REFRESH_TOKEN_SUCCESS)
.do((action: RefreshTokenSuccessAction) => this.authService.replaceToken(action.payload));
/**
* When the store is rehydrated in the browser,
* clear a possible invalid token or authentication errors
*/
@Effect({dispatch: false})
public clearInvalidTokenOnRehydrate$: Observable<any> = this.actions$
.ofType(StoreActionTypes.REHYDRATE)
.switchMap(() => {
return this.store.select(isAuthenticated)
.take(1)
.filter((authenticated) => !authenticated)
.do(() => this.authService.removeToken())
.do(() => this.authService.resetAuthenticationError());
});
@Effect()
public logOut$: Observable<Action> = this.actions$
.ofType(AuthActionTypes.LOG_OUT)
.switchMap(() => {
return this.authService.logout()
.map((value) => new LogOutSuccessAction())
.catch((error) => Observable.of(new LogOutErrorAction(error)));
});
@Effect({dispatch: false})
public logOutSuccess$: Observable<Action> = this.actions$
.ofType(AuthActionTypes.LOG_OUT_SUCCESS)
.do(() => this.authService.removeToken())
.do(() => this.authService.clearRedirectUrl())
.do(() => this.authService.refreshAfterLogout());
@Effect({dispatch: false})
public redirectToLogin$: Observable<Action> = this.actions$
.ofType(AuthActionTypes.REDIRECT_AUTHENTICATION_REQUIRED)
.do(() => this.authService.removeToken())
.do(() => this.authService.redirectToLogin());
@Effect({dispatch: false})
public redirectToLoginTokenExpired$: Observable<Action> = this.actions$
.ofType(AuthActionTypes.REDIRECT_TOKEN_EXPIRED)
.do(() => this.authService.removeToken())
.do(() => this.authService.redirectToLoginWhenTokenExpired());
/**
* @constructor
* @param {Actions} actions$
* @param {AuthService} authService
* @param {Store} store
*/
constructor(private actions$: Actions,
private authService: AuthService,
private store: Store<AppState>) {
}
}

View File

@@ -0,0 +1,98 @@
import { TestBed } from '@angular/core/testing';
import { HttpClientTestingModule, HttpTestingController, } from '@angular/common/http/testing';
import { HTTP_INTERCEPTORS } from '@angular/common/http';
import { Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { Observable } from 'rxjs/Observable';
import { AuthInterceptor } from './auth.interceptor';
import { AuthService } from './auth.service';
import { DSpaceRESTv2Service } from '../dspace-rest-v2/dspace-rest-v2.service';
import { RestRequestMethod } from '../data/request.models';
import { RouterStub } from '../../shared/testing/router-stub';
import { TruncatablesState } from '../../shared/truncatable/truncatable.reducer';
import { AuthServiceStub } from '../../shared/testing/auth-service-stub';
describe(`AuthInterceptor`, () => {
let service: DSpaceRESTv2Service;
let httpMock: HttpTestingController;
const authServiceStub = new AuthServiceStub();
const store: Store<TruncatablesState> = jasmine.createSpyObj('store', {
/* tslint:disable:no-empty */
dispatch: {},
/* tslint:enable:no-empty */
select: Observable.of(true)
});
beforeEach(() => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [
DSpaceRESTv2Service,
{provide: AuthService, useValue: authServiceStub},
{provide: Router, useClass: RouterStub},
{
provide: HTTP_INTERCEPTORS,
useClass: AuthInterceptor,
multi: true,
},
{provide: Store, useValue: store},
],
});
service = TestBed.get(DSpaceRESTv2Service);
httpMock = TestBed.get(HttpTestingController);
});
describe('when has a valid token', () => {
it('should not add an Authorization header when were sending a HTTP request to \'authn\' endpoint', () => {
service.request(RestRequestMethod.Post, 'dspace-spring-rest/api/authn/login', 'password=password&user=user').subscribe((response) => {
expect(response).toBeTruthy();
});
const httpRequest = httpMock.expectOne(`dspace-spring-rest/api/authn/login`);
const token = httpRequest.request.headers.get('authorization');
expect(token).toBeNull();
});
it('should add an Authorization header when were sending a HTTP request to \'authn\' endpoint', () => {
service.request(RestRequestMethod.Post, 'dspace-spring-rest/api/submission/workspaceitems', 'test').subscribe((response) => {
expect(response).toBeTruthy();
});
const httpRequest = httpMock.expectOne(`dspace-spring-rest/api/submission/workspaceitems`);
expect(httpRequest.request.headers.has('authorization'));
const token = httpRequest.request.headers.get('authorization');
expect(token).toBe('Bearer token_test');
});
});
describe('when has an expired token', () => {
beforeEach(() => {
authServiceStub.setTokenAsExpired();
});
afterEach(() => {
authServiceStub.setTokenAsNotExpired();
});
it('should redirect to login', () => {
service.request(RestRequestMethod.Post, 'dspace-spring-rest/api/submission/workspaceitems', 'password=password&user=user').subscribe((response) => {
expect(response).toBeTruthy();
});
service.request(RestRequestMethod.Post, 'dspace-spring-rest/api/submission/workspaceitems', 'password=password&user=user');
httpMock.expectNone('dspace-spring-rest/api/submission/workspaceitems');
});
})
});

View File

@@ -0,0 +1,153 @@
import { Injectable, Injector } from '@angular/core';
import {
HttpEvent, HttpInterceptor, HttpHandler, HttpRequest, HttpResponse,
HttpErrorResponse, HttpResponseBase
} from '@angular/common/http';
import { Observable } from 'rxjs/Rx';
import 'rxjs/add/observable/throw'
import 'rxjs/add/operator/catch';
import { find } from 'lodash';
import { AppState } from '../../app.reducer';
import { AuthService } from './auth.service';
import { AuthStatus } from './models/auth-status.model';
import { AuthTokenInfo } from './models/auth-token-info.model';
import { isNotEmpty, isUndefined } from '../../shared/empty.util';
import { RedirectWhenTokenExpiredAction, RefreshTokenAction } from './auth.actions';
import { Store } from '@ngrx/store';
import { Router } from '@angular/router';
@Injectable()
export class AuthInterceptor implements HttpInterceptor {
// Intercetor is called twice per request,
// so to prevent RefreshTokenAction is dispatched twice
// we're creating a refresh token request list
protected refreshTokenRequestUrls = [];
constructor(private inj: Injector, private router: Router, private store: Store<AppState>) { }
private isUnauthorized(response: HttpResponseBase): boolean {
// invalid_token The access token provided is expired, revoked, malformed, or invalid for other reasons
return response.status === 401;
}
private isSuccess(response: HttpResponseBase): boolean {
return response.status === 200;
}
private isAuthRequest(http: HttpRequest<any> | HttpResponseBase): boolean {
return http && http.url
&& (http.url.endsWith('/authn/login')
|| http.url.endsWith('/authn/logout')
|| http.url.endsWith('/authn/status'));
}
private isLoginResponse(http: HttpRequest<any> | HttpResponseBase): boolean {
return http.url && http.url.endsWith('/authn/login');
}
private isLogoutResponse(http: HttpRequest<any> | HttpResponseBase): boolean {
return http.url && http.url.endsWith('/authn/logout');
}
private makeAuthStatusObject(authenticated:boolean, accessToken?: string, error?: string): AuthStatus {
const authStatus = new AuthStatus();
authStatus.id = null;
authStatus.okay = true;
if (authenticated) {
authStatus.authenticated = true;
authStatus.token = new AuthTokenInfo(accessToken);
} else {
authStatus.authenticated = false;
authStatus.error = isNotEmpty(error) ? ((typeof error === 'string') ? JSON.parse(error) : error) : null;
}
return authStatus;
}
intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
const authService = this.inj.get(AuthService);
const token = authService.getToken();
let newReq;
if (authService.isTokenExpired()) {
authService.setRedirectUrl(this.router.url);
// The access token is expired
// Redirect to the login route
this.store.dispatch(new RedirectWhenTokenExpiredAction('auth.messages.expired'));
return Observable.of(null);
} else if (!this.isAuthRequest(req) && isNotEmpty(token)) {
// Intercept a request that is not to the authentication endpoint
authService.isTokenExpiring()
.filter((isExpiring) => isExpiring)
.subscribe(() => {
// If the current request url is already in the refresh token request list, skip it
if (isUndefined(find(this.refreshTokenRequestUrls, req.url))) {
// When a token is about to expire, refresh it
this.store.dispatch(new RefreshTokenAction(token));
this.refreshTokenRequestUrls.push(req.url);
}
});
// Get the auth header from the service.
const Authorization = authService.buildAuthHeader(token);
// Clone the request to add the new header.
newReq = req.clone({headers: req.headers.set('authorization', Authorization)});
} else {
newReq = req;
}
// Pass on the new request instead of the original request.
return next.handle(newReq)
.map((response) => {
// Intercept a Login/Logout response
if (response instanceof HttpResponse && this.isSuccess(response) && (this.isLoginResponse(response) || this.isLogoutResponse(response))) {
// It's a success Login/Logout response
let authRes: HttpResponse<any>;
if (this.isLoginResponse(response)) {
// login successfully
const newToken = response.headers.get('authorization');
authRes = response.clone({body: this.makeAuthStatusObject(true, newToken)});
// clean eventually refresh Requests list
this.refreshTokenRequestUrls = [];
} else {
// logout successfully
authRes = response.clone({body: this.makeAuthStatusObject(false)});
}
return authRes;
} else {
return response;
}
})
.catch((error, caught) => {
// Intercept an error response
if (error instanceof HttpErrorResponse) {
// Checks if is a response from a request to an authentication endpoint
if (this.isAuthRequest(error)) {
// clean eventually refresh Requests list
this.refreshTokenRequestUrls = [];
// Create a new HttpResponse and return it, so it can be handle properly by AuthService.
const authResponse = new HttpResponse({
body: this.makeAuthStatusObject(false, null, error.error),
headers: error.headers,
status: error.status,
statusText: error.statusText,
url: error.url
});
return Observable.of(authResponse);
} else if (this.isUnauthorized(error)) {
// The access token provided is expired, revoked, malformed, or invalid for other reasons
// Redirect to the login route
this.store.dispatch(new RedirectWhenTokenExpiredAction('auth.messages.expired'));
}
}
// Return error response as is.
return Observable.throw(error);
}) as any;
}
}

View File

@@ -0,0 +1,411 @@
import { authReducer, AuthState } from './auth.reducer';
import {
AddAuthenticationMessageAction,
AuthenticateAction,
AuthenticatedAction,
AuthenticatedErrorAction,
AuthenticatedSuccessAction,
AuthenticationErrorAction,
AuthenticationSuccessAction,
CheckAuthenticationTokenAction,
CheckAuthenticationTokenErrorAction,
LogOutAction,
LogOutErrorAction,
LogOutSuccessAction,
RedirectWhenAuthenticationIsRequiredAction,
RedirectWhenTokenExpiredAction,
RefreshTokenAction,
RefreshTokenErrorAction,
RefreshTokenSuccessAction,
ResetAuthenticationMessagesAction,
SetRedirectUrlAction
} from './auth.actions';
import { AuthTokenInfo } from './models/auth-token-info.model';
import { EpersonMock } from '../../shared/testing/eperson-mock';
describe('authReducer', () => {
let initialState: AuthState;
let state: AuthState;
const mockTokenInfo = new AuthTokenInfo('test_token');
const mockError = new Error('Test error message');
it('should properly set the state, in response to a AUTHENTICATE action', () => {
initialState = {
authenticated: false,
loaded: false,
loading: false,
};
const action = new AuthenticateAction('user', 'password');
const newState = authReducer(initialState, action);
state = {
authenticated: false,
loaded: false,
error: undefined,
loading: true,
info: undefined
};
expect(newState).toEqual(state);
});
it('should properly set the state, in response to a AUTHENTICATE_SUCCESS action', () => {
initialState = {
authenticated: false,
loaded: false,
error: undefined,
loading: true,
info: undefined
};
const action = new AuthenticationSuccessAction(mockTokenInfo);
const newState = authReducer(initialState, action);
expect(newState).toEqual(initialState);
});
it('should properly set the state, in response to a AUTHENTICATE_ERROR action', () => {
initialState = {
authenticated: false,
loaded: false,
error: undefined,
loading: true,
info: undefined
};
const action = new AuthenticationErrorAction(mockError);
const newState = authReducer(initialState, action);
state = {
authenticated: false,
loaded: false,
loading: false,
info: undefined,
authToken: undefined,
error: 'Test error message'
};
expect(newState).toEqual(state);
});
it('should properly set the state, in response to a AUTHENTICATED action', () => {
initialState = {
authenticated: false,
loaded: false,
error: undefined,
loading: true,
info: undefined
};
const action = new AuthenticatedAction(mockTokenInfo);
const newState = authReducer(initialState, action);
expect(newState).toEqual(initialState);
});
it('should properly set the state, in response to a AUTHENTICATED_SUCCESS action', () => {
initialState = {
authenticated: false,
loaded: false,
error: undefined,
loading: true,
info: undefined
};
const action = new AuthenticatedSuccessAction(true, mockTokenInfo, EpersonMock);
const newState = authReducer(initialState, action);
state = {
authenticated: true,
authToken: mockTokenInfo,
loaded: true,
error: undefined,
loading: false,
info: undefined,
user: EpersonMock
};
expect(newState).toEqual(state);
});
it('should properly set the state, in response to a AUTHENTICATED_ERROR action', () => {
initialState = {
authenticated: false,
loaded: false,
error: undefined,
loading: true,
info: undefined
};
const action = new AuthenticatedErrorAction(mockError);
const newState = authReducer(initialState, action);
state = {
authenticated: false,
authToken: undefined,
error: 'Test error message',
loaded: true,
loading: false,
info: undefined
};
expect(newState).toEqual(state);
});
it('should properly set the state, in response to a CHECK_AUTHENTICATION_TOKEN action', () => {
initialState = {
authenticated: false,
loaded: false,
loading: false,
};
const action = new CheckAuthenticationTokenAction();
const newState = authReducer(initialState, action);
state = {
authenticated: false,
loaded: false,
loading: true,
};
expect(newState).toEqual(state);
});
it('should properly set the state, in response to a CHECK_AUTHENTICATION_TOKEN_ERROR action', () => {
initialState = {
authenticated: false,
loaded: false,
loading: true,
};
const action = new CheckAuthenticationTokenErrorAction();
const newState = authReducer(initialState, action);
state = {
authenticated: false,
loaded: false,
loading: false,
};
expect(newState).toEqual(state);
});
it('should properly set the state, in response to a LOG_OUT action', () => {
initialState = {
authenticated: true,
authToken: mockTokenInfo,
loaded: true,
error: undefined,
loading: false,
info: undefined,
user: EpersonMock
};
const action = new LogOutAction();
const newState = authReducer(initialState, action);
expect(newState).toEqual(initialState);
});
it('should properly set the state, in response to a LOG_OUT_SUCCESS action', () => {
initialState = {
authenticated: true,
authToken: mockTokenInfo,
loaded: true,
error: undefined,
loading: false,
info: undefined,
user: EpersonMock
};
const action = new LogOutSuccessAction();
const newState = authReducer(initialState, action);
state = {
authenticated: false,
authToken: undefined,
error: undefined,
loaded: false,
loading: false,
info: undefined,
refreshing: false,
user: undefined
};
expect(newState).toEqual(state);
});
it('should properly set the state, in response to a LOG_OUT_ERROR action', () => {
initialState = {
authenticated: true,
authToken: mockTokenInfo,
loaded: true,
error: undefined,
loading: false,
info: undefined,
user: EpersonMock
};
const action = new LogOutErrorAction(mockError);
const newState = authReducer(initialState, action);
state = {
authenticated: true,
authToken: mockTokenInfo,
loaded: true,
error: 'Test error message',
loading: false,
info: undefined,
user: EpersonMock
};
expect(newState).toEqual(state);
});
it('should properly set the state, in response to a REFRESH_TOKEN action', () => {
initialState = {
authenticated: true,
authToken: mockTokenInfo,
loaded: true,
error: undefined,
loading: false,
info: undefined,
user: EpersonMock
};
const newTokenInfo = new AuthTokenInfo('Refreshed token');
const action = new RefreshTokenAction(newTokenInfo);
const newState = authReducer(initialState, action);
state = {
authenticated: true,
authToken: mockTokenInfo,
loaded: true,
error: undefined,
loading: false,
info: undefined,
user: EpersonMock,
refreshing: true
};
expect(newState).toEqual(state);
});
it('should properly set the state, in response to a REFRESH_TOKEN_SUCCESS action', () => {
initialState = {
authenticated: true,
authToken: mockTokenInfo,
loaded: true,
error: undefined,
loading: false,
info: undefined,
user: EpersonMock,
refreshing: true
};
const newTokenInfo = new AuthTokenInfo('Refreshed token');
const action = new RefreshTokenSuccessAction(newTokenInfo);
const newState = authReducer(initialState, action);
state = {
authenticated: true,
authToken: newTokenInfo,
loaded: true,
error: undefined,
loading: false,
info: undefined,
user: EpersonMock,
refreshing: false
};
expect(newState).toEqual(state);
});
it('should properly set the state, in response to a REFRESH_TOKEN_ERROR action', () => {
initialState = {
authenticated: true,
authToken: mockTokenInfo,
loaded: true,
error: undefined,
loading: false,
info: undefined,
user: EpersonMock,
refreshing: true
};
const action = new RefreshTokenErrorAction();
const newState = authReducer(initialState, action);
state = {
authenticated: false,
authToken: undefined,
error: undefined,
loaded: false,
loading: false,
info: undefined,
refreshing: false,
user: undefined
};
expect(newState).toEqual(state);
});
beforeEach(() => {
initialState = {
authenticated: true,
authToken: mockTokenInfo,
loaded: true,
error: undefined,
loading: false,
info: undefined,
user: EpersonMock
};
state = {
authenticated: false,
authToken: undefined,
loaded: false,
loading: false,
error: undefined,
info: 'Message',
user: undefined
};
});
it('should properly set the state, in response to a REDIRECT_AUTHENTICATION_REQUIRED action', () => {
const action = new RedirectWhenAuthenticationIsRequiredAction('Message');
const newState = authReducer(initialState, action);
expect(newState).toEqual(state);
});
it('should properly set the state, in response to a REDIRECT_TOKEN_EXPIRED action', () => {
const action = new RedirectWhenTokenExpiredAction('Message');
const newState = authReducer(initialState, action);
expect(newState).toEqual(state);
});
it('should properly set the state, in response to a ADD_MESSAGE action', () => {
initialState = {
authenticated: false,
loaded: false,
loading: false,
};
const action = new AddAuthenticationMessageAction('Message');
const newState = authReducer(initialState, action);
state = {
authenticated: false,
loaded: false,
loading: false,
info: 'Message'
};
expect(newState).toEqual(state);
});
it('should properly set the state, in response to a RESET_MESSAGES action', () => {
initialState = {
authenticated: false,
loaded: false,
loading: false,
error: 'Error',
info: 'Message'
};
const action = new ResetAuthenticationMessagesAction();
const newState = authReducer(initialState, action);
state = {
authenticated: false,
loaded: false,
loading: false,
error: undefined,
info: undefined
};
expect(newState).toEqual(state);
});
it('should properly set the state, in response to a SET_REDIRECT_URL action', () => {
initialState = {
authenticated: false,
loaded: false,
loading: false
};
const action = new SetRedirectUrlAction('redirect.url');
const newState = authReducer(initialState, action);
state = {
authenticated: false,
loaded: false,
loading: false,
redirectUrl: 'redirect.url'
};
expect(newState).toEqual(state);
});
});

View File

@@ -0,0 +1,198 @@
// import actions
import {
AddAuthenticationMessageAction,
AuthActions,
AuthActionTypes,
AuthenticatedSuccessAction,
AuthenticationErrorAction,
LogOutErrorAction,
RedirectWhenAuthenticationIsRequiredAction,
RedirectWhenTokenExpiredAction,
RefreshTokenSuccessAction,
SetRedirectUrlAction
} from './auth.actions';
// import models
import { Eperson } from '../eperson/models/eperson.model';
import { AuthTokenInfo } from './models/auth-token-info.model';
/**
* The auth state.
* @interface State
*/
export interface AuthState {
// boolean if user is authenticated
authenticated: boolean;
// the authentication token
authToken?: AuthTokenInfo;
// error message
error?: string;
// true if we have attempted existing auth session
loaded: boolean;
// true when loading
loading: boolean;
// info message
info?: string;
// redirect url after login
redirectUrl?: string;
// true when refreshing token
refreshing?: boolean;
// the authenticated user
user?: Eperson;
}
/**
* The initial state.
*/
const initialState: AuthState = {
authenticated: false,
loaded: false,
loading: false,
};
/**
* The reducer function.
* @function reducer
* @param {State} state Current state
* @param {AuthActions} action Incoming action
*/
export function authReducer(state: any = initialState, action: AuthActions): AuthState {
switch (action.type) {
case AuthActionTypes.AUTHENTICATE:
return Object.assign({}, state, {
error: undefined,
loading: true,
info: undefined
});
case AuthActionTypes.AUTHENTICATED:
return Object.assign({}, state, {
loading: true
});
case AuthActionTypes.AUTHENTICATED_ERROR:
return Object.assign({}, state, {
authenticated: false,
authToken: undefined,
error: (action as AuthenticationErrorAction).payload.message,
loaded: true,
loading: false
});
case AuthActionTypes.AUTHENTICATED_SUCCESS:
return Object.assign({}, state, {
authenticated: true,
authToken: (action as AuthenticatedSuccessAction).payload.authToken,
loaded: true,
error: undefined,
loading: false,
info: undefined,
user: (action as AuthenticatedSuccessAction).payload.user
});
case AuthActionTypes.AUTHENTICATE_ERROR:
case AuthActionTypes.REGISTRATION_ERROR:
return Object.assign({}, state, {
authenticated: false,
authToken: undefined,
error: (action as AuthenticationErrorAction).payload.message,
loading: false
});
case AuthActionTypes.AUTHENTICATED:
case AuthActionTypes.AUTHENTICATE_SUCCESS:
case AuthActionTypes.LOG_OUT:
return state;
case AuthActionTypes.CHECK_AUTHENTICATION_TOKEN:
return Object.assign({}, state, {
loading: true
});
case AuthActionTypes.CHECK_AUTHENTICATION_TOKEN_ERROR:
return Object.assign({}, state, {
loading: false
});
case AuthActionTypes.LOG_OUT_ERROR:
return Object.assign({}, state, {
authenticated: true,
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,
loading: false,
info: undefined,
refreshing: false,
user: undefined
});
case AuthActionTypes.REDIRECT_AUTHENTICATION_REQUIRED:
case AuthActionTypes.REDIRECT_TOKEN_EXPIRED:
return Object.assign({}, state, {
authenticated: false,
authToken: undefined,
loaded: false,
loading: false,
info: (action as RedirectWhenTokenExpiredAction as RedirectWhenAuthenticationIsRequiredAction).payload,
user: undefined
});
case AuthActionTypes.REGISTRATION:
return Object.assign({}, state, {
authenticated: false,
authToken: undefined,
error: undefined,
loading: true,
info: undefined
});
case AuthActionTypes.REGISTRATION_SUCCESS:
return state;
case AuthActionTypes.REFRESH_TOKEN:
return Object.assign({}, state, {
refreshing: true,
});
case AuthActionTypes.REFRESH_TOKEN_SUCCESS:
return Object.assign({}, state, {
authToken: (action as RefreshTokenSuccessAction).payload,
refreshing: false,
});
case AuthActionTypes.ADD_MESSAGE:
return Object.assign({}, state, {
info: (action as AddAuthenticationMessageAction).payload,
});
case AuthActionTypes.RESET_MESSAGES:
return Object.assign({}, state, {
error: undefined,
info: undefined,
});
case AuthActionTypes.SET_REDIRECT_URL:
return Object.assign({}, state, {
redirectUrl: (action as SetRedirectUrlAction).payload,
});
default:
return state;
}
}

View File

@@ -0,0 +1,221 @@
import { async, inject, TestBed } from '@angular/core/testing';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router } from '@angular/router';
import { Store, StoreModule } from '@ngrx/store';
import { Observable } from 'rxjs/Observable';
import { REQUEST } from '@nguniversal/express-engine/tokens';
import 'rxjs/add/observable/of';
import { authReducer, AuthState } from './auth.reducer';
import { NativeWindowRef, NativeWindowService } from '../../shared/services/window.service';
import { AuthService } from './auth.service';
import { RouterStub } from '../../shared/testing/router-stub';
import { ActivatedRouteStub } from '../../shared/testing/active-router-stub';
import { CookieService } from '../../shared/services/cookie.service';
import { AuthRequestServiceStub } from '../../shared/testing/auth-request-service-stub';
import { AuthRequestService } from './auth-request.service';
import { AuthStatus } from './models/auth-status.model';
import { AuthTokenInfo } from './models/auth-token-info.model';
import { Eperson } from '../eperson/models/eperson.model';
import { EpersonMock } from '../../shared/testing/eperson-mock';
import { AppState } from '../../app.reducer';
import { ClientCookieService } from '../../shared/services/client-cookie.service';
describe('AuthService test', () => {
const mockStore: Store<AuthState> = jasmine.createSpyObj('store', {
dispatch: {},
select: Observable.of(true)
});
let authService: AuthService;
const authRequest = new AuthRequestServiceStub();
const window = new NativeWindowRef();
const routerStub = new RouterStub();
const routeStub = new ActivatedRouteStub();
let storage: CookieService;
const token: AuthTokenInfo = new AuthTokenInfo('test_token');
token.expires = Date.now() + (1000 * 60 * 60);
let authenticatedState = {
authenticated: true,
loaded: true,
loading: false,
authToken: token,
user: EpersonMock
};
describe('', () => {
beforeEach(() => {
TestBed.configureTestingModule({
imports: [
CommonModule,
StoreModule.forRoot({authReducer}),
],
declarations: [],
providers: [
{provide: AuthRequestService, useValue: authRequest},
{provide: NativeWindowService, useValue: window},
{provide: REQUEST, useValue: {}},
{provide: Router, useValue: routerStub},
{provide: ActivatedRoute, useValue: routeStub},
{provide: Store, useValue: mockStore},
CookieService,
AuthService
],
});
authService = TestBed.get(AuthService);
});
it('should return the authentication status object when user credentials are correct', () => {
authService.authenticate('user', 'password').subscribe((status: AuthStatus) => {
expect(status).toBeDefined();
});
});
it('should throw an error when user credentials are wrong', () => {
expect(authService.authenticate.bind(null, 'user', 'passwordwrong')).toThrow();
});
it('should return the authenticated user object when user token is valid', () => {
authService.authenticatedUser(new AuthTokenInfo('test_token')).subscribe((user: Eperson) => {
expect(user).toBeDefined();
});
});
it('should throw an error when user credentials when user token is not valid', () => {
expect(authService.authenticatedUser.bind(null, new AuthTokenInfo('test_token_expired'))).toThrow();
});
it('should return a valid refreshed token', () => {
authService.refreshAuthenticationToken(new AuthTokenInfo('test_token')).subscribe((tokenState: AuthTokenInfo) => {
expect(tokenState).toBeDefined();
});
});
it('should throw an error when is not possible to refresh token', () => {
expect(authService.refreshAuthenticationToken.bind(null, new AuthTokenInfo('test_token_expired'))).toThrow();
});
it('should return true when logout succeeded', () => {
authService.logout().subscribe((status: boolean) => {
expect(status).toBe(true);
});
});
it('should throw an error when logout is not succeeded', () => {
expect(authService.logout.bind(null)).toThrow();
});
});
describe('', () => {
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [
StoreModule.forRoot({authReducer})
],
providers: [
{provide: AuthRequestService, useValue: authRequest},
{provide: REQUEST, useValue: {}},
{provide: Router, useValue: routerStub},
CookieService
]
}).compileComponents();
}));
beforeEach(inject([CookieService, AuthRequestService, Store, Router], (cookieService: CookieService, authReqService: AuthRequestService, store: Store<AppState>, router: Router) => {
store
.subscribe((state) => {
(state as any).core = Object.create({});
(state as any).core.auth = authenticatedState;
});
authService = new AuthService({}, window, authReqService, router, cookieService, store);
}));
it('should return true when user is logged in', () => {
authService.isAuthenticated().subscribe((status: boolean) => {
expect(status).toBe(true);
});
});
it('should return token object when it is valid', () => {
authService.hasValidAuthenticationToken().subscribe((tokenState: AuthTokenInfo) => {
expect(tokenState).toBe(token);
});
});
it('should return a token object', () => {
const result = authService.getToken();
expect(result).toBe(token);
});
it('should return false when token is not expired', () => {
const result = authService.isTokenExpired();
expect(result).toBe(false);
});
});
describe('', () => {
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [
StoreModule.forRoot({authReducer})
],
providers: [
{provide: AuthRequestService, useValue: authRequest},
{provide: REQUEST, useValue: {}},
{provide: Router, useValue: routerStub},
ClientCookieService,
CookieService
]
}).compileComponents();
}));
beforeEach(inject([ClientCookieService, AuthRequestService, Store, Router], (cookieService: ClientCookieService, authReqService: AuthRequestService, store: Store<AppState>, router: Router) => {
const expiredToken: AuthTokenInfo = new AuthTokenInfo('test_token');
expiredToken.expires = Date.now() - (1000 * 60 * 60);
authenticatedState = {
authenticated: true,
loaded: true,
loading: false,
authToken: expiredToken,
user: EpersonMock
};
store
.subscribe((state) => {
(state as any).core = Object.create({});
(state as any).core.auth = authenticatedState;
});
authService = new AuthService({}, window, authReqService, router, cookieService, store);
storage = (authService as any).storage;
spyOn(storage, 'get');
spyOn(storage, 'remove');
spyOn(storage, 'set');
}));
it('should throw false when token is not valid', () => {
expect(authService.hasValidAuthenticationToken.bind(null)).toThrow();
});
it('should return true when token is expired', () => {
const result = authService.isTokenExpired();
expect(result).toBe(true);
});
it('should save token into storage', () => {
authService.storeToken(token);
expect(storage.set).toHaveBeenCalled();
});
it('should remove token from storage', () => {
authService.removeToken();
expect(storage.remove).toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,389 @@
import { Inject, Injectable } from '@angular/core';
import { PRIMARY_OUTLET, Router, UrlSegmentGroup, UrlTree } from '@angular/router';
import { HttpHeaders } from '@angular/common/http';
import { REQUEST } from '@nguniversal/express-engine/tokens';
import { RouterReducerState } from '@ngrx/router-store';
import { Store } from '@ngrx/store';
import { CookieAttributes } from 'js-cookie';
import { Observable } from 'rxjs/Observable';
import { map, withLatestFrom } from 'rxjs/operators';
import { Eperson } from '../eperson/models/eperson.model';
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 { isEmpty, isNotEmpty, isNotNull, isNotUndefined } from '../../shared/empty.util';
import { CookieService } from '../../shared/services/cookie.service';
import { getAuthenticationToken, getRedirectUrl, isAuthenticated, isTokenRefreshing } from './selectors';
import { AppState, routerStateSelector } from '../../app.reducer';
import { ResetAuthenticationMessagesAction, SetRedirectUrlAction } from './auth.actions';
import { NativeWindowRef, NativeWindowService } from '../../shared/services/window.service';
import { Base64EncodeUrl } from '../../shared/utils/encode-decode.util';
export const LOGIN_ROUTE = '/login';
export const LOGOUT_ROUTE = '/logout';
export const REDIRECT_COOKIE = 'dsRedirectUrl';
/**
* The auth service.
*/
@Injectable()
export class AuthService {
/**
* True if authenticated
* @type boolean
*/
protected _authenticated: boolean;
constructor(@Inject(REQUEST) protected req: any,
@Inject(NativeWindowService) protected _window: NativeWindowRef,
protected authRequestService: AuthRequestService,
protected router: Router,
protected storage: CookieService,
protected store: Store<AppState>) {
this.store.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.select(routerStateSelector)
.filter((routerState: RouterReducerState) => isNotUndefined(routerState) && isNotUndefined(routerState.state))
.filter((routerState: RouterReducerState) => !this.isLoginRoute(routerState.state.url))
.map((routerState: RouterReducerState) => routerState.state.url);
const redirectUrl$ = this.store.select(getRedirectUrl).distinctUntilChanged();
routeUrl$.pipe(
withLatestFrom(redirectUrl$),
map(([routeUrl, redirectUrl]) => [routeUrl, redirectUrl])
).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;
}
/**
* Authenticate the user
*
* @param {string} user The user name
* @param {string} password The user's password
* @returns {Observable<User>} The authenticated user observable.
*/
public authenticate(user: string, password: string): Observable<AuthStatus> {
// Attempt authenticating the user using the supplied credentials.
const body = (`password=${Base64EncodeUrl(password)}&user=${Base64EncodeUrl(user)}`);
const options: HttpOptions = Object.create({});
let headers = new HttpHeaders();
headers = headers.append('Content-Type', 'application/x-www-form-urlencoded');
options.headers = headers;
return this.authRequestService.postToEndpoint('login', body, options)
.map((status: AuthStatus) => {
if (status.authenticated) {
return status;
} else {
throw(new Error('Invalid email or password'));
}
})
}
/**
* Determines if the user is authenticated
* @returns {Observable<boolean>}
*/
public isAuthenticated(): Observable<boolean> {
return this.store.select(isAuthenticated);
}
/**
* Returns the authenticated user
* @returns {User}
*/
public authenticatedUser(token: AuthTokenInfo): Observable<Eperson> {
// Determine if the user has an existing auth session on the server
const options: HttpOptions = Object.create({});
let headers = new HttpHeaders();
headers = headers.append('Accept', 'application/json');
headers = headers.append('Authorization', `Bearer ${token.accessToken}`);
options.headers = headers;
return this.authRequestService.getRequest('status', options)
.map((status: AuthStatus) => {
if (status.authenticated) {
return status.eperson[0];
} else {
throw(new Error('Not authenticated'));
}
});
}
/**
* Checks if token is present into browser storage and is valid. (NB Check is done only on SSR)
*/
public checkAuthenticationToken() {
return
}
/**
* Checks if token is present into storage and is not expired
*/
public hasValidAuthenticationToken(): Observable<AuthTokenInfo> {
return this.store.select(getAuthenticationToken)
.take(1)
.map((authTokenInfo: AuthTokenInfo) => {
let token: AuthTokenInfo;
// Retrieve authentication token info and check if is valid
token = isNotEmpty(authTokenInfo) ? authTokenInfo : this.storage.get(TOKENITEM);
if (isNotEmpty(token) && token.hasOwnProperty('accessToken') && isNotEmpty(token.accessToken) && !this.isTokenExpired(token)) {
return token;
} else {
throw false;
}
});
}
/**
* Checks if token is present into storage
*/
public refreshAuthenticationToken(token: AuthTokenInfo): Observable<AuthTokenInfo> {
const options: HttpOptions = Object.create({});
let headers = new HttpHeaders();
headers = headers.append('Accept', 'application/json');
headers = headers.append('Authorization', `Bearer ${token.accessToken}`);
options.headers = headers;
return this.authRequestService.postToEndpoint('login', {}, options)
.map((status: AuthStatus) => {
if (status.authenticated) {
return status.token;
} else {
throw(new Error('Not authenticated'));
}
});
}
/**
* Clear authentication errors
*/
public resetAuthenticationError(): void {
this.store.dispatch(new ResetAuthenticationMessagesAction());
}
/**
* Create a new user
* @returns {User}
*/
public create(user: Eperson): Observable<Eperson> {
// Normally you would do an HTTP request to POST the user
// details and then return the new user object
// but, let's just return the new user for this example.
// this._authenticated = true;
return Observable.of(user);
}
/**
* End session
* @returns {Observable<boolean>}
*/
public logout(): Observable<boolean> {
// Send a request that sign end the session
let headers = new HttpHeaders();
headers = headers.append('Content-Type', 'application/x-www-form-urlencoded');
const options: HttpOptions = Object.create({headers, responseType: 'text'});
return this.authRequestService.getRequest('logout', options)
.map((status: AuthStatus) => {
if (!status.authenticated) {
return true;
} else {
throw(new Error('auth.errors.invalid-user'));
}
})
}
/**
* Retrieve authentication token info and make authorization header
* @returns {string}
*/
public buildAuthHeader(token?: AuthTokenInfo): string {
if (isEmpty(token)) {
token = this.getToken();
}
return (this._authenticated && isNotNull(token)) ? `Bearer ${token.accessToken}` : '';
}
/**
* Get authentication token info
* @returns {AuthTokenInfo}
*/
public getToken(): AuthTokenInfo {
let token: AuthTokenInfo;
this.store.select(getAuthenticationToken)
.subscribe((authTokenInfo: AuthTokenInfo) => {
// Retrieve authentication token info and check if is valid
token = authTokenInfo || null;
});
return token;
}
/**
* Check if a token is next to be expired
* @returns {boolean}
*/
public isTokenExpiring(): Observable<boolean> {
return this.store.select(isTokenRefreshing)
.first()
.map((isRefreshing: boolean) => {
if (this.isTokenExpired() || isRefreshing) {
return false;
} else {
const token = this.getToken();
return token.expires - (60 * 5 * 1000) < Date.now();
}
})
}
/**
* Check if a token is expired
* @returns {boolean}
*/
public isTokenExpired(token?: AuthTokenInfo): boolean {
token = token || this.getToken();
return token && token.expires < Date.now();
}
/**
* Save authentication token info
*
* @param {AuthTokenInfo} token The token to save
* @returns {AuthTokenInfo}
*/
public storeToken(token: AuthTokenInfo) {
// Add 1 day to the current date
const expireDate = Date.now() + (1000 * 60 * 60 * 24);
// Set the cookie expire date
const expires = new Date(expireDate);
const options: CookieAttributes = {expires: expires};
// Save cookie with the token
return this.storage.set(TOKENITEM, token, options);
}
/**
* Remove authentication token info
*/
public removeToken() {
return this.storage.remove(TOKENITEM);
}
/**
* Replace authentication token info with a new one
*/
public replaceToken(token: AuthTokenInfo) {
this.removeToken();
return this.storeToken(token);
}
/**
* Redirect to the login route
*/
public redirectToLogin() {
this.router.navigate([LOGIN_ROUTE]);
}
/**
* Redirect to the login route when token has expired
*/
public redirectToLoginWhenTokenExpired() {
const redirectUrl = LOGIN_ROUTE + '?expired=true';
if (this._window.nativeWindow.location) {
// Hard redirect to login page, so that all state is definitely lost
this._window.nativeWindow.location.href = redirectUrl;
} else {
this.router.navigateByUrl(redirectUrl);
}
}
/**
* Redirect to the route navigated before the login
*/
public redirectToPreviousUrl() {
this.getRedirectUrl()
.first()
.subscribe((redirectUrl) => {
if (isNotEmpty(redirectUrl)) {
this.clearRedirectUrl();
// override the route reuse strategy
this.router.routeReuseStrategy.shouldReuseRoute = () => {
return false;
};
this.router.navigated = false;
const url = decodeURIComponent(redirectUrl);
this.router.navigateByUrl(url);
} else {
this.router.navigate(['/']);
}
})
}
/**
* Refresh route navigated
*/
public refreshAfterLogout() {
this.router.navigate(['/home']);
// Hard redirect to home page, so that all state is definitely lost
this._window.nativeWindow.location.href = '/home';
}
/**
* Get redirect url
*/
getRedirectUrl(): Observable<string> {
const redirectUrl = this.storage.get(REDIRECT_COOKIE);
if (isNotEmpty(redirectUrl)) {
return Observable.of(redirectUrl);
} else {
return this.store.select(getRedirectUrl);
}
}
/**
* Set redirect url
*/
setRedirectUrl(url: string) {
// Add 1 hour to the current date
const expireDate = Date.now() + (1000 * 60 * 60);
// Set the cookie expire date
const expires = new Date(expireDate);
const options: CookieAttributes = {expires: expires};
this.storage.set(REDIRECT_COOKIE, url, options);
this.store.dispatch(new SetRedirectUrlAction(isNotUndefined(url) ? url : ''));
}
/**
* Clear redirect url
*/
clearRedirectUrl() {
this.store.dispatch(new SetRedirectUrlAction(''));
this.storage.remove(REDIRECT_COOKIE);
}
}

View File

@@ -0,0 +1,70 @@
import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, CanActivate, CanLoad, Route, Router, RouterStateSnapshot } from '@angular/router';
import { Observable } from 'rxjs/Observable';
import { Store } from '@ngrx/store';
// reducers
import { CoreState } from '../core.reducers';
import { isAuthenticated, isAuthenticationLoading } from './selectors';
import { AuthService } from './auth.service';
import { RedirectWhenAuthenticationIsRequiredAction } from './auth.actions';
import { isEmpty } from '../../shared/empty.util';
/**
* Prevent unauthorized activating and loading of routes
* @class AuthenticatedGuard
*/
@Injectable()
export class AuthenticatedGuard implements CanActivate, CanLoad {
/**
* @constructor
*/
constructor(private authService: AuthService, private router: Router, private store: Store<CoreState>) {}
/**
* True when user is authenticated
* @method canActivate
*/
canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<boolean> {
const url = state.url;
return this.handleAuth(url);
}
/**
* True when user is authenticated
* @method canActivateChild
*/
canActivateChild(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<boolean> {
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.select(isAuthenticated);
// redirect to sign in page if user is not authenticated
observable
// .filter(() => isEmpty(this.router.routerState.snapshot.url) || this.router.routerState.snapshot.url === url)
.take(1)
.subscribe((authenticated) => {
if (!authenticated) {
this.authService.setRedirectUrl(url);
this.store.dispatch(new RedirectWhenAuthenticationIsRequiredAction('Login required'));
}
});
return observable;
}
}

View File

@@ -0,0 +1,7 @@
export interface AuthError {
error: string,
message: string,
path: string,
status: number
timestamp: number
}

View File

@@ -0,0 +1,21 @@
import { AuthError } from './auth-error.model';
import { AuthTokenInfo } from './auth-token-info.model';
import { DSpaceObject } from '../../shared/dspace-object.model';
import { Eperson } from '../../eperson/models/eperson.model';
export class AuthStatus {
id: string;
okay: boolean;
authenticated: boolean;
error?: AuthError;
eperson: Eperson[];
token?: AuthTokenInfo;
self: string;
}

View File

@@ -0,0 +1,19 @@
import { default as decode } from 'jwt-decode';
export const TOKENITEM = 'dsAuthInfo';
export class AuthTokenInfo {
public accessToken: string;
public expires: number;
constructor(token: string) {
this.accessToken = token.replace('Bearer ', '');
try {
const tokenClaims = decode(this.accessToken);
// exp claim is in seconds, convert it se to milliseconds
this.expires = tokenClaims.exp * 1000;
} catch (err) {
this.expires = 0;
}
}
}

View File

@@ -0,0 +1,26 @@
import { AuthStatus } from './auth-status.model';
import { autoserialize, autoserializeAs, inheritSerialization } from 'cerialize';
import { mapsTo } from '../../cache/builders/build-decorators';
import { NormalizedDSpaceObject } from '../../cache/models/normalized-dspace-object.model';
import { Eperson } from '../../eperson/models/eperson.model';
@mapsTo(AuthStatus)
@inheritSerialization(NormalizedDSpaceObject)
export class NormalizedAuthStatus extends NormalizedDSpaceObject {
/**
* True if REST API is up and running, should never return false
*/
@autoserialize
okay: boolean;
/**
* True if the token is valid, false if there was no token or the token wasn't valid
*/
@autoserialize
authenticated: boolean;
@autoserializeAs(Eperson)
eperson: Eperson[];
}

View File

@@ -0,0 +1,204 @@
import { createSelector } from '@ngrx/store';
/**
* Every reducer module's default export is the reducer function itself. In
* addition, each module should export a type or interface that describes
* the state of the reducer plus any selector functions. The `* as`
* notation packages up all of the exports into a single object.
*/
import { AuthState } from './auth.reducer';
import { AppState } from '../../app.reducer';
/**
* Returns the user state.
* @function getUserState
* @param {AppState} state Top level state.
* @return {AuthState}
*/
export const getAuthState = (state: any) => state.core.auth;
/**
* Returns true if the user is authenticated.
* @function _isAuthenticated
* @param {State} state
* @returns {boolean}
*/
const _isAuthenticated = (state: AuthState) => state.authenticated;
/**
* Returns true if the authenticated has loaded.
* @function _isAuthenticatedLoaded
* @param {State} state
* @returns {boolean}
*/
const _isAuthenticatedLoaded = (state: AuthState) => state.loaded;
/**
* Return the users state
* @function _getAuthenticatedUser
* @param {State} state
* @returns {User}
*/
const _getAuthenticatedUser = (state: AuthState) => state.user;
/**
* Returns the authentication error.
* @function _getAuthenticationError
* @param {State} state
* @returns {string}
*/
const _getAuthenticationError = (state: AuthState) => state.error;
/**
* Returns the authentication info message.
* @function _getAuthenticationInfo
* @param {State} state
* @returns {string}
*/
const _getAuthenticationInfo = (state: AuthState) => state.info;
/**
* Returns true if request is in progress.
* @function _isLoading
* @param {State} state
* @returns {boolean}
*/
const _isLoading = (state: AuthState) => state.loading;
/**
* Returns true if a refresh token request is in progress.
* @function _isRefreshing
* @param {State} state
* @returns {boolean}
*/
const _isRefreshing = (state: AuthState) => state.refreshing;
/**
* Returns the authentication token.
* @function _getAuthenticationToken
* @param {State} state
* @returns {AuthToken}
*/
const _getAuthenticationToken = (state: AuthState) => state.authToken;
/**
* Returns the sign out error.
* @function _getLogOutError
* @param {State} state
* @returns {string}
*/
const _getLogOutError = (state: AuthState) => state.error;
/**
* Returns the sign up error.
* @function _getRegistrationError
* @param {State} state
* @returns {string}
*/
const _getRegistrationError = (state: AuthState) => state.error;
/**
* Returns the redirect url.
* @function _getRedirectUrl
* @param {State} state
* @returns {string}
*/
const _getRedirectUrl = (state: AuthState) => state.redirectUrl;
/**
* Returns the authenticated user
* @function getAuthenticatedUser
* @param {AuthState} state
* @param {any} props
* @return {User}
*/
export const getAuthenticatedUser = createSelector(getAuthState, _getAuthenticatedUser);
/**
* Returns the authentication error.
* @function getAuthenticationError
* @param {AuthState} state
* @param {any} props
* @return {Error}
*/
export const getAuthenticationError = createSelector(getAuthState, _getAuthenticationError);
/**
* Returns the authentication info message.
* @function getAuthenticationInfo
* @param {AuthState} state
* @param {any} props
* @return {string}
*/
export const getAuthenticationInfo = createSelector(getAuthState, _getAuthenticationInfo);
/**
* Returns true if the user is authenticated
* @function isAuthenticated
* @param {AuthState} state
* @param {any} props
* @return {boolean}
*/
export const isAuthenticated = createSelector(getAuthState, _isAuthenticated);
/**
* Returns true if the user is authenticated
* @function isAuthenticated
* @param {AuthState} state
* @param {any} props
* @return {boolean}
*/
export const isAuthenticatedLoaded = createSelector(getAuthState, _isAuthenticatedLoaded);
/**
* Returns true if the authentication request is loading.
* @function isAuthenticationLoading
* @param {AuthState} state
* @param {any} props
* @return {boolean}
*/
export const isAuthenticationLoading = createSelector(getAuthState, _isLoading);
/**
* Returns true if the refresh token request is loading.
* @function isTokenRefreshing
* @param {AuthState} state
* @param {any} props
* @return {boolean}
*/
export const isTokenRefreshing = createSelector(getAuthState, _isRefreshing);
/**
* Returns the authentication token.
* @function getAuthenticationToken
* @param {State} state
* @returns {AuthToken}
*/
export const getAuthenticationToken = createSelector(getAuthState, _getAuthenticationToken);
/**
* Returns the log out error.
* @function getLogOutError
* @param {AuthState} state
* @param {any} props
* @return {Error}
*/
export const getLogOutError = createSelector(getAuthState, _getLogOutError);
/**
* Returns the registration error.
* @function getRegistrationError
* @param {AuthState} state
* @param {any} props
* @return {Error}
*/
export const getRegistrationError = createSelector(getAuthState, _getRegistrationError);
/**
* Returns the redirect url.
* @function getRedirectUrl
* @param {AuthState} state
* @param {any} props
* @return {string}
*/
export const getRedirectUrl = createSelector(getAuthState, _getRedirectUrl);

View File

@@ -0,0 +1,74 @@
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs/Observable';
import { HttpHeaders } from '@angular/common/http';
import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service';
import { AuthStatus } from './models/auth-status.model';
import { isNotEmpty } from '../../shared/empty.util';
import { AuthService } from './auth.service';
import { AuthTokenInfo } from './models/auth-token-info.model';
import { CheckAuthenticationTokenAction } from './auth.actions';
import { Eperson } from '../eperson/models/eperson.model';
/**
* The auth service.
*/
@Injectable()
export class ServerAuthService extends AuthService {
/**
* Returns the authenticated user
* @returns {User}
*/
public authenticatedUser(token: AuthTokenInfo): Observable<Eperson> {
// Determine if the user has an existing auth session on the server
const options: HttpOptions = Object.create({});
let headers = new HttpHeaders();
headers = headers.append('Accept', 'application/json');
headers = headers.append('Authorization', `Bearer ${token.accessToken}`);
// NB this is used to pass server client IP check.
const clientIp = this.req.get('x-forwarded-for') || this.req.connection.remoteAddress;
headers = headers.append('X-Forwarded-For', clientIp);
options.headers = headers;
return this.authRequestService.getRequest('status', options)
.map((status: AuthStatus) => {
if (status.authenticated) {
return status.eperson[0];
} else {
throw(new Error('Not authenticated'));
}
});
}
/**
* Checks if token is present into browser storage and is valid. (NB Check is done only on SSR)
*/
public checkAuthenticationToken() {
this.store.dispatch(new CheckAuthenticationTokenAction())
}
/**
* Redirect to the route navigated before the login
*/
public redirectToPreviousUrl() {
this.getRedirectUrl()
.first()
.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 {
this.router.navigate(['/']);
}
})
}
}

View File

@@ -7,12 +7,20 @@ import { ConfigObject } from '../shared/config/config.model';
import { FacetValue } from '../../+search-page/search-service/facet-value.model'; import { FacetValue } from '../../+search-page/search-service/facet-value.model';
import { SearchFilterConfig } from '../../+search-page/search-service/search-filter-config.model'; import { SearchFilterConfig } from '../../+search-page/search-service/search-filter-config.model';
import { IntegrationModel } from '../integration/models/integration.model'; import { IntegrationModel } from '../integration/models/integration.model';
import { RegistryMetadataschemasResponse } from '../registry/registry-metadataschemas-response.model';
import { MetadataSchema } from '../metadata/metadataschema.model';
import { RegistryMetadatafieldsResponse } from '../registry/registry-metadatafields-response.model';
import { RegistryBitstreamformatsResponse } from '../registry/registry-bitstreamformats-response.model';
import { AuthTokenInfo } from '../auth/models/auth-token-info.model';
import { NormalizedAuthStatus } from '../auth/models/normalized-auth-status.model';
import { AuthStatus } from '../auth/models/auth-status.model';
/* tslint:disable:max-classes-per-file */ /* tslint:disable:max-classes-per-file */
export class RestResponse { export class RestResponse {
public toCache = true;
constructor( constructor(
public isSuccessful: boolean, public isSuccessful: boolean,
public statusCode: string public statusCode: string,
) { } ) { }
} }
@@ -26,6 +34,45 @@ export class DSOSuccessResponse extends RestResponse {
} }
} }
export class RegistryMetadataschemasSuccessResponse extends RestResponse {
constructor(
public metadataschemasResponse: RegistryMetadataschemasResponse,
public statusCode: string,
public pageInfo?: PageInfo
) {
super(true, statusCode);
}
}
export class RegistryMetadatafieldsSuccessResponse extends RestResponse {
constructor(
public metadatafieldsResponse: RegistryMetadatafieldsResponse,
public statusCode: string,
public pageInfo?: PageInfo
) {
super(true, statusCode);
}
}
export class RegistryBitstreamformatsSuccessResponse extends RestResponse {
constructor(
public bitstreamformatsResponse: RegistryBitstreamformatsResponse,
public statusCode: string,
public pageInfo?: PageInfo
) {
super(true, statusCode);
}
}
export class MetadataschemaSuccessResponse extends RestResponse {
constructor(
public metadataschema: MetadataSchema,
public statusCode: string
) {
super(true, statusCode);
}
}
export class SearchSuccessResponse extends RestResponse { export class SearchSuccessResponse extends RestResponse {
constructor( constructor(
public results: SearchQueryResponse, public results: SearchQueryResponse,
@@ -110,6 +157,17 @@ export class ConfigSuccessResponse extends RestResponse {
} }
} }
export class AuthStatusResponse extends RestResponse {
public toCache = false;
constructor(
public response: AuthStatus,
public statusCode: string
) {
super(true, statusCode);
}
}
export class IntegrationSuccessResponse extends RestResponse { export class IntegrationSuccessResponse extends RestResponse {
constructor( constructor(
public dataDefinition: IntegrationModel[], public dataDefinition: IntegrationModel[],

View File

@@ -65,6 +65,11 @@ export class ResponseCacheService {
return result; return result;
} }
remove(key: string): void {
if (this.has(key)) {
this.store.dispatch(new ResponseCacheRemoveAction(key));
}
}
/** /**
* Check whether a ResponseCacheEntry should still be cached * Check whether a ResponseCacheEntry should still be cached
* *

View File

@@ -3,10 +3,12 @@ import { ObjectCacheEffects } from './cache/object-cache.effects';
import { ResponseCacheEffects } from './cache/response-cache.effects'; import { ResponseCacheEffects } from './cache/response-cache.effects';
import { UUIDIndexEffects } from './index/index.effects'; import { UUIDIndexEffects } from './index/index.effects';
import { RequestEffects } from './data/request.effects'; import { RequestEffects } from './data/request.effects';
import { AuthEffects } from './auth/auth.effects';
export const coreEffects = [ export const coreEffects = [
ResponseCacheEffects, ResponseCacheEffects,
RequestEffects, RequestEffects,
ObjectCacheEffects, ObjectCacheEffects,
UUIDIndexEffects UUIDIndexEffects,
AuthEffects
]; ];

View File

@@ -15,7 +15,7 @@ import { coreReducers } from './core.reducers';
import { isNotEmpty } from '../shared/empty.util'; import { isNotEmpty } from '../shared/empty.util';
import { ApiService } from '../shared/api.service'; import { ApiService } from '../shared/services/api.service';
import { BrowseEntriesResponseParsingService } from './data/browse-entries-response-parsing.service'; import { BrowseEntriesResponseParsingService } from './data/browse-entries-response-parsing.service';
import { CollectionDataService } from './data/collection-data.service'; import { CollectionDataService } from './data/collection-data.service';
import { CommunityDataService } from './data/community-data.service'; import { CommunityDataService } from './data/community-data.service';
@@ -34,22 +34,32 @@ import { RemoteDataBuildService } from './cache/builders/remote-data-build.servi
import { RequestService } from './data/request.service'; import { RequestService } from './data/request.service';
import { ResponseCacheService } from './cache/response-cache.service'; import { ResponseCacheService } from './cache/response-cache.service';
import { EndpointMapResponseParsingService } from './data/endpoint-map-response-parsing.service'; import { EndpointMapResponseParsingService } from './data/endpoint-map-response-parsing.service';
import { ServerResponseService } from '../shared/server-response.service'; import { ServerResponseService } from '../shared/services/server-response.service';
import { NativeWindowFactory, NativeWindowService } from '../shared/window.service'; import { NativeWindowFactory, NativeWindowService } from '../shared/services/window.service';
import { BrowseService } from './browse/browse.service'; import { BrowseService } from './browse/browse.service';
import { BrowseResponseParsingService } from './data/browse-response-parsing.service'; import { BrowseResponseParsingService } from './data/browse-response-parsing.service';
import { ConfigResponseParsingService } from './data/config-response-parsing.service'; import { ConfigResponseParsingService } from './data/config-response-parsing.service';
import { RouteService } from '../shared/route.service'; import { RouteService } from '../shared/services/route.service';
import { SubmissionDefinitionsConfigService } from './config/submission-definitions-config.service'; import { SubmissionDefinitionsConfigService } from './config/submission-definitions-config.service';
import { SubmissionFormsConfigService } from './config/submission-forms-config.service'; import { SubmissionFormsConfigService } from './config/submission-forms-config.service';
import { SubmissionSectionsConfigService } from './config/submission-sections-config.service'; import { SubmissionSectionsConfigService } from './config/submission-sections-config.service';
import { AuthorityService } from './integration/authority.service'; import { AuthorityService } from './integration/authority.service';
import { IntegrationResponseParsingService } from './integration/integration-response-parsing.service'; import { IntegrationResponseParsingService } from './integration/integration-response-parsing.service';
import { UUIDService } from './shared/uuid.service'; import { UUIDService } from './shared/uuid.service';
import { AuthenticatedGuard } from './auth/authenticated.guard';
import { AuthRequestService } from './auth/auth-request.service';
import { AuthResponseParsingService } from './auth/auth-response-parsing.service';
import { HTTP_INTERCEPTORS } from '@angular/common/http';
import { AuthInterceptor } from './auth/auth.interceptor';
import { HALEndpointService } from './shared/hal-endpoint.service'; import { HALEndpointService } from './shared/hal-endpoint.service';
import { FacetValueResponseParsingService } from './data/facet-value-response-parsing.service'; import { FacetValueResponseParsingService } from './data/facet-value-response-parsing.service';
import { FacetValueMapResponseParsingService } from './data/facet-value-map-response-parsing.service'; import { FacetValueMapResponseParsingService } from './data/facet-value-map-response-parsing.service';
import { FacetConfigResponseParsingService } from './data/facet-config-response-parsing.service'; import { FacetConfigResponseParsingService } from './data/facet-config-response-parsing.service';
import { RegistryService } from './registry/registry.service';
import { RegistryMetadataschemasResponseParsingService } from './data/registry-metadataschemas-response-parsing.service';
import { MetadataschemaParsingService } from './data/metadataschema-parsing.service';
import { RegistryMetadatafieldsResponseParsingService } from './data/registry-metadatafields-response-parsing.service';
import { RegistryBitstreamformatsResponseParsingService } from './data/registry-bitstreamformats-response-parsing.service';
import { NotificationsService } from '../shared/notifications/notifications.service'; import { NotificationsService } from '../shared/notifications/notifications.service';
import { UploaderService } from '../shared/uploader/uploader.service'; import { UploaderService } from '../shared/uploader/uploader.service';
@@ -69,6 +79,9 @@ const EXPORTS = [
const PROVIDERS = [ const PROVIDERS = [
ApiService, ApiService,
AuthenticatedGuard,
AuthRequestService,
AuthResponseParsingService,
CommunityDataService, CommunityDataService,
CollectionDataService, CollectionDataService,
DSOResponseParsingService, DSOResponseParsingService,
@@ -84,6 +97,7 @@ const PROVIDERS = [
MetadataService, MetadataService,
ObjectCacheService, ObjectCacheService,
PaginationComponentOptions, PaginationComponentOptions,
RegistryService,
RemoteDataBuildService, RemoteDataBuildService,
RequestService, RequestService,
ResponseCacheService, ResponseCacheService,
@@ -91,6 +105,10 @@ const PROVIDERS = [
FacetValueResponseParsingService, FacetValueResponseParsingService,
FacetValueMapResponseParsingService, FacetValueMapResponseParsingService,
FacetConfigResponseParsingService, FacetConfigResponseParsingService,
RegistryMetadataschemasResponseParsingService,
RegistryMetadatafieldsResponseParsingService,
RegistryBitstreamformatsResponseParsingService,
MetadataschemaParsingService,
DebugResponseParsingService, DebugResponseParsingService,
SearchResponseParsingService, SearchResponseParsingService,
ServerResponseService, ServerResponseService,
@@ -106,6 +124,12 @@ const PROVIDERS = [
IntegrationResponseParsingService, IntegrationResponseParsingService,
UploaderService, UploaderService,
UUIDService, UUIDService,
// register AuthInterceptor as HttpInterceptor
{
provide: HTTP_INTERCEPTORS,
useClass: AuthInterceptor,
multi: true
},
NotificationsService, NotificationsService,
{ provide: NativeWindowService, useFactory: NativeWindowFactory } { provide: NativeWindowService, useFactory: NativeWindowFactory }
]; ];

View File

@@ -4,19 +4,22 @@ import { responseCacheReducer, ResponseCacheState } from './cache/response-cache
import { objectCacheReducer, ObjectCacheState } from './cache/object-cache.reducer'; import { objectCacheReducer, ObjectCacheState } from './cache/object-cache.reducer';
import { indexReducer, IndexState } from './index/index.reducer'; import { indexReducer, IndexState } from './index/index.reducer';
import { requestReducer, RequestState } from './data/request.reducer'; import { requestReducer, RequestState } from './data/request.reducer';
import { authReducer, AuthState } from './auth/auth.reducer';
export interface CoreState { export interface CoreState {
'data/object': ObjectCacheState, 'data/object': ObjectCacheState,
'data/response': ResponseCacheState, 'data/response': ResponseCacheState,
'data/request': RequestState, 'data/request': RequestState,
'index': IndexState 'index': IndexState,
'auth': AuthState,
} }
export const coreReducers: ActionReducerMap<CoreState> = { export const coreReducers: ActionReducerMap<CoreState> = {
'data/object': objectCacheReducer, 'data/object': objectCacheReducer,
'data/response': responseCacheReducer, 'data/response': responseCacheReducer,
'data/request': requestReducer, 'data/request': requestReducer,
'index': indexReducer 'index': indexReducer,
'auth': authReducer
}; };
export const coreSelector = createFeatureSelector<CoreState>('core'); export const coreSelector = createFeatureSelector<CoreState>('core');

View File

@@ -0,0 +1,19 @@
import { MetadataSchema } from '../metadata/metadataschema.model';
import { DSpaceRESTv2Serializer } from '../dspace-rest-v2/dspace-rest-v2.serializer';
import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model';
import { RestRequest } from './request.models';
import { ResponseParsingService } from './parsing.service';
import { Injectable } from '@angular/core';
import { MetadataschemaSuccessResponse, RestResponse } from '../cache/response-cache.models';
@Injectable()
export class MetadataschemaParsingService implements ResponseParsingService {
parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse {
const payload = data.payload;
const deserialized = new DSpaceRESTv2Serializer(MetadataSchema).deserialize(payload);
return new MetadataschemaSuccessResponse(deserialized, data.statusCode);
}
}

View File

@@ -0,0 +1,25 @@
import { RegistryBitstreamformatsSuccessResponse, RestResponse } from '../cache/response-cache.models';
import { RegistryBitstreamformatsResponse } from '../registry/registry-bitstreamformats-response.model';
import { DSpaceRESTv2Serializer } from '../dspace-rest-v2/dspace-rest-v2.serializer';
import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model';
import { RestRequest } from './request.models';
import { DSOResponseParsingService } from './dso-response-parsing.service';
import { ResponseParsingService } from './parsing.service';
import { Injectable } from '@angular/core';
@Injectable()
export class RegistryBitstreamformatsResponseParsingService implements ResponseParsingService {
constructor(private dsoParser: DSOResponseParsingService) {
}
parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse {
const payload = data.payload;
const bitstreamformats = payload._embedded.bitstreamformats;
payload.bitstreamformats = bitstreamformats;
const deserialized = new DSpaceRESTv2Serializer(RegistryBitstreamformatsResponse).deserialize(payload);
return new RegistryBitstreamformatsSuccessResponse(deserialized, data.statusCode, this.dsoParser.processPageInfo(data.payload.page));
}
}

View File

@@ -0,0 +1,34 @@
import {
RegistryMetadatafieldsSuccessResponse, RegistryMetadataschemasSuccessResponse,
RestResponse
} from '../cache/response-cache.models';
import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model';
import { RestRequest } from './request.models';
import { ResponseParsingService } from './parsing.service';
import { RegistryMetadataschemasResponse } from '../registry/registry-metadataschemas-response.model';
import { DSpaceRESTv2Serializer } from '../dspace-rest-v2/dspace-rest-v2.serializer';
import { DSOResponseParsingService } from './dso-response-parsing.service';
import { Injectable } from '@angular/core';
import { forEach } from '@angular/router/src/utils/collection';
import { RegistryMetadatafieldsResponse } from '../registry/registry-metadatafields-response.model';
@Injectable()
export class RegistryMetadatafieldsResponseParsingService implements ResponseParsingService {
constructor(private dsoParser: DSOResponseParsingService) {
}
parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse {
const payload = data.payload;
const metadatafields = payload._embedded.metadatafields;
metadatafields.forEach((field) => {
field.schema = field._embedded.schema;
});
payload.metadatafields = metadatafields;
const deserialized = new DSpaceRESTv2Serializer(RegistryMetadatafieldsResponse).deserialize(payload);
return new RegistryMetadatafieldsSuccessResponse(deserialized, data.statusCode, this.dsoParser.processPageInfo(data.payload.page));
}
}

View File

@@ -0,0 +1,25 @@
import { RegistryMetadataschemasSuccessResponse, RestResponse } from '../cache/response-cache.models';
import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model';
import { RestRequest } from './request.models';
import { ResponseParsingService } from './parsing.service';
import { RegistryMetadataschemasResponse } from '../registry/registry-metadataschemas-response.model';
import { DSpaceRESTv2Serializer } from '../dspace-rest-v2/dspace-rest-v2.serializer';
import { DSOResponseParsingService } from './dso-response-parsing.service';
import { Injectable } from '@angular/core';
@Injectable()
export class RegistryMetadataschemasResponseParsingService implements ResponseParsingService {
constructor(private dsoParser: DSOResponseParsingService) {
}
parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse {
const payload = data.payload;
const metadataschemas = payload._embedded.metadataschemas;
payload.metadataschemas = metadataschemas;
const deserialized = new DSpaceRESTv2Serializer(RegistryMetadataschemasResponse).deserialize(payload);
return new RegistryMetadataschemasSuccessResponse(deserialized, data.statusCode, this.dsoParser.processPageInfo(data.payload.page));
}
}

View File

@@ -33,9 +33,9 @@ export class RequestEffects {
let body; let body;
if (isNotEmpty(request.body)) { if (isNotEmpty(request.body)) {
const serializer = new DSpaceRESTv2Serializer(NormalizedObjectFactory.getConstructor(request.body.type)); const serializer = new DSpaceRESTv2Serializer(NormalizedObjectFactory.getConstructor(request.body.type));
body = JSON.stringify(serializer.serialize(request.body)); body = serializer.serialize(request.body);
} }
return this.restApi.request(request.method, request.href, body) return this.restApi.request(request.method, request.href, body, request.options)
.map((data: DSpaceRESTV2Response) => .map((data: DSpaceRESTV2Response) =>
this.injector.get(request.getResponseParser()).parse(request, data)) this.injector.get(request.getResponseParser()).parse(request, data))
.do((response: RestResponse) => this.responseCache.add(request.href, response, this.EnvConfig.cache.msToLive)) .do((response: RestResponse) => this.responseCache.add(request.href, response, this.EnvConfig.cache.msToLive))

View File

@@ -8,6 +8,9 @@ import { ResponseParsingService } from './parsing.service';
import { EndpointMapResponseParsingService } from './endpoint-map-response-parsing.service'; import { EndpointMapResponseParsingService } from './endpoint-map-response-parsing.service';
import { BrowseResponseParsingService } from './browse-response-parsing.service'; import { BrowseResponseParsingService } from './browse-response-parsing.service';
import { ConfigResponseParsingService } from './config-response-parsing.service'; import { ConfigResponseParsingService } from './config-response-parsing.service';
import { AuthResponseParsingService } from '../auth/auth-response-parsing.service';
import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service';
import { HttpHeaders } from '@angular/common/http';
import { IntegrationResponseParsingService } from '../integration/integration-response-parsing.service'; import { IntegrationResponseParsingService } from '../integration/integration-response-parsing.service';
/* tslint:disable:max-classes-per-file */ /* tslint:disable:max-classes-per-file */
@@ -36,7 +39,8 @@ export abstract class RestRequest {
public uuid: string, public uuid: string,
public href: string, public href: string,
public method: RestRequestMethod = RestRequestMethod.Get, public method: RestRequestMethod = RestRequestMethod.Get,
public body?: any public body?: any,
public options?: HttpOptions
) { ) {
} }
@@ -49,7 +53,8 @@ export class GetRequest extends RestRequest {
constructor( constructor(
public uuid: string, public uuid: string,
public href: string, public href: string,
public body?: any public body?: any,
public options?: HttpOptions
) { ) {
super(uuid, href, RestRequestMethod.Get, body) super(uuid, href, RestRequestMethod.Get, body)
} }
@@ -59,7 +64,8 @@ export class PostRequest extends RestRequest {
constructor( constructor(
public uuid: string, public uuid: string,
public href: string, public href: string,
public body?: any public body?: any,
public options?: HttpOptions
) { ) {
super(uuid, href, RestRequestMethod.Post, body) super(uuid, href, RestRequestMethod.Post, body)
} }
@@ -69,7 +75,8 @@ export class PutRequest extends RestRequest {
constructor( constructor(
public uuid: string, public uuid: string,
public href: string, public href: string,
public body?: any public body?: any,
public options?: HttpOptions
) { ) {
super(uuid, href, RestRequestMethod.Put, body) super(uuid, href, RestRequestMethod.Put, body)
} }
@@ -79,7 +86,8 @@ export class DeleteRequest extends RestRequest {
constructor( constructor(
public uuid: string, public uuid: string,
public href: string, public href: string,
public body?: any public body?: any,
public options?: HttpOptions
) { ) {
super(uuid, href, RestRequestMethod.Delete, body) super(uuid, href, RestRequestMethod.Delete, body)
} }
@@ -89,7 +97,8 @@ export class OptionsRequest extends RestRequest {
constructor( constructor(
public uuid: string, public uuid: string,
public href: string, public href: string,
public body?: any public body?: any,
public options?: HttpOptions
) { ) {
super(uuid, href, RestRequestMethod.Options, body) super(uuid, href, RestRequestMethod.Options, body)
} }
@@ -99,7 +108,8 @@ export class HeadRequest extends RestRequest {
constructor( constructor(
public uuid: string, public uuid: string,
public href: string, public href: string,
public body?: any public body?: any,
public options?: HttpOptions
) { ) {
super(uuid, href, RestRequestMethod.Head, body) super(uuid, href, RestRequestMethod.Head, body)
} }
@@ -109,7 +119,8 @@ export class PatchRequest extends RestRequest {
constructor( constructor(
public uuid: string, public uuid: string,
public href: string, public href: string,
public body?: any public body?: any,
public options?: HttpOptions
) { ) {
super(uuid, href, RestRequestMethod.Patch, body) super(uuid, href, RestRequestMethod.Patch, body)
} }
@@ -136,7 +147,7 @@ export class FindAllRequest extends GetRequest {
constructor( constructor(
uuid: string, uuid: string,
href: string, href: string,
public options?: FindAllOptions, public body?: FindAllOptions,
) { ) {
super(uuid, href); super(uuid, href);
} }
@@ -182,6 +193,26 @@ export class ConfigRequest extends GetRequest {
} }
} }
export class AuthPostRequest extends PostRequest {
constructor(uuid: string, href: string, public body?: any, public options?: HttpOptions) {
super(uuid, href, body, options);
}
getResponseParser(): GenericConstructor<ResponseParsingService> {
return AuthResponseParsingService;
}
}
export class AuthGetRequest extends GetRequest {
constructor(uuid: string, href: string, public options?: HttpOptions) {
super(uuid, href, null, options);
}
getResponseParser(): GenericConstructor<ResponseParsingService> {
return AuthResponseParsingService;
}
}
export class IntegrationRequest extends GetRequest { export class IntegrationRequest extends GetRequest {
constructor(uuid: string, href: string) { constructor(uuid: string, href: string) {
super(uuid, href); super(uuid, href);

View File

@@ -1,6 +1,7 @@
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { cold, hot } from 'jasmine-marbles'; import { cold, hot } from 'jasmine-marbles';
import { Observable } from 'rxjs/Observable'; import { Observable } from 'rxjs/Observable';
import 'rxjs/add/observable/of';
import { getMockObjectCacheService } from '../../shared/mocks/mock-object-cache.service'; import { getMockObjectCacheService } from '../../shared/mocks/mock-object-cache.service';
import { getMockResponseCacheService } from '../../shared/mocks/mock-response-cache.service'; import { getMockResponseCacheService } from '../../shared/mocks/mock-response-cache.service';
import { getMockStore } from '../../shared/mocks/mock-store'; import { getMockStore } from '../../shared/mocks/mock-store';
@@ -229,6 +230,11 @@ describe('RequestService', () => {
request = testGetRequest; request = testGetRequest;
}); });
it('should track it on it\'s way to the store', () => {
spyOn(serviceAsAny, 'trackRequestsOnTheirWayToTheStore');
service.configure(request);
expect(serviceAsAny.trackRequestsOnTheirWayToTheStore).toHaveBeenCalledWith(request);
});
describe('and it isn\'t cached or pending', () => { describe('and it isn\'t cached or pending', () => {
beforeEach(() => { beforeEach(() => {
spyOn(serviceAsAny, 'isCachedOrPending').and.returnValue(false); spyOn(serviceAsAny, 'isCachedOrPending').and.returnValue(false);
@@ -271,6 +277,28 @@ describe('RequestService', () => {
service.configure(testPatchRequest); service.configure(testPatchRequest);
expect(serviceAsAny.dispatchRequest).toHaveBeenCalledWith(testPatchRequest); expect(serviceAsAny.dispatchRequest).toHaveBeenCalledWith(testPatchRequest);
}); });
it('shouldn\'t track it on it\'s way to the store', () => {
spyOn(serviceAsAny, 'trackRequestsOnTheirWayToTheStore');
serviceAsAny.dispatchRequest(testPostRequest);
expect(serviceAsAny.trackRequestsOnTheirWayToTheStore).not.toHaveBeenCalled();
serviceAsAny.dispatchRequest(testPutRequest);
expect(serviceAsAny.trackRequestsOnTheirWayToTheStore).not.toHaveBeenCalled();
serviceAsAny.dispatchRequest(testDeleteRequest);
expect(serviceAsAny.trackRequestsOnTheirWayToTheStore).not.toHaveBeenCalled();
serviceAsAny.dispatchRequest(testOptionsRequest);
expect(serviceAsAny.trackRequestsOnTheirWayToTheStore).not.toHaveBeenCalled();
serviceAsAny.dispatchRequest(testHeadRequest);
expect(serviceAsAny.trackRequestsOnTheirWayToTheStore).not.toHaveBeenCalled();
serviceAsAny.dispatchRequest(testPatchRequest);
expect(serviceAsAny.trackRequestsOnTheirWayToTheStore).not.toHaveBeenCalled();
});
}); });
}); });
@@ -381,43 +409,6 @@ describe('RequestService', () => {
serviceAsAny.dispatchRequest(request); serviceAsAny.dispatchRequest(request);
expect(store.dispatch).toHaveBeenCalledWith(new RequestExecuteAction(request.uuid)); expect(store.dispatch).toHaveBeenCalledWith(new RequestExecuteAction(request.uuid));
}); });
describe('when it\'s a GET request', () => {
let request: RestRequest;
beforeEach(() => {
request = testGetRequest;
});
it('should track it on it\'s way to the store', () => {
spyOn(serviceAsAny, 'trackRequestsOnTheirWayToTheStore');
serviceAsAny.dispatchRequest(request);
expect(serviceAsAny.trackRequestsOnTheirWayToTheStore).toHaveBeenCalledWith(request);
});
});
describe('when it\'s not a GET request', () => {
it('shouldn\'t track it', () => {
spyOn(serviceAsAny, 'trackRequestsOnTheirWayToTheStore');
serviceAsAny.dispatchRequest(testPostRequest);
expect(serviceAsAny.trackRequestsOnTheirWayToTheStore).not.toHaveBeenCalled();
serviceAsAny.dispatchRequest(testPutRequest);
expect(serviceAsAny.trackRequestsOnTheirWayToTheStore).not.toHaveBeenCalled();
serviceAsAny.dispatchRequest(testDeleteRequest);
expect(serviceAsAny.trackRequestsOnTheirWayToTheStore).not.toHaveBeenCalled();
serviceAsAny.dispatchRequest(testOptionsRequest);
expect(serviceAsAny.trackRequestsOnTheirWayToTheStore).not.toHaveBeenCalled();
serviceAsAny.dispatchRequest(testHeadRequest);
expect(serviceAsAny.trackRequestsOnTheirWayToTheStore).not.toHaveBeenCalled();
serviceAsAny.dispatchRequest(testPatchRequest);
expect(serviceAsAny.trackRequestsOnTheirWayToTheStore).not.toHaveBeenCalled();
});
});
}); });
describe('trackRequestsOnTheirWayToTheStore', () => { describe('trackRequestsOnTheirWayToTheStore', () => {

View File

@@ -17,6 +17,7 @@ import { RequestConfigureAction, RequestExecuteAction } from './request.actions'
import { GetRequest, RestRequest, RestRequestMethod } from './request.models'; import { GetRequest, RestRequest, RestRequestMethod } from './request.models';
import { RequestEntry, RequestState } from './request.reducer'; import { RequestEntry, RequestState } from './request.reducer';
import { ResponseCacheRemoveAction } from '../cache/response-cache.actions';
@Injectable() @Injectable()
export class RequestService { export class RequestService {
@@ -66,9 +67,14 @@ export class RequestService {
.flatMap((uuid: string) => this.getByUUID(uuid)); .flatMap((uuid: string) => this.getByUUID(uuid));
} }
configure<T extends CacheableObject>(request: RestRequest): void { // TODO to review "overrideRequest" param when https://github.com/DSpace/dspace-angular/issues/217 will be fixed
if (request.method !== RestRequestMethod.Get || !this.isCachedOrPending(request)) { configure<T extends CacheableObject>(request: RestRequest, forceBypassCache: boolean = false): void {
const isGetRequest = request.method === RestRequestMethod.Get;
if (!isGetRequest || !this.isCachedOrPending(request) || forceBypassCache) {
this.dispatchRequest(request); this.dispatchRequest(request);
if (isGetRequest && !forceBypassCache) {
this.trackRequestsOnTheirWayToTheStore(request);
}
} }
} }
@@ -104,9 +110,6 @@ export class RequestService {
private dispatchRequest(request: RestRequest) { private dispatchRequest(request: RestRequest) {
this.store.dispatch(new RequestConfigureAction(request)); this.store.dispatch(new RequestConfigureAction(request));
this.store.dispatch(new RequestExecuteAction(request.uuid)); this.store.dispatch(new RequestExecuteAction(request.uuid));
if (request.method === RestRequestMethod.Get) {
this.trackRequestsOnTheirWayToTheStore(request);
}
} }
/** /**

View File

@@ -1,3 +1,5 @@
import { HttpHeaders } from '@angular/common/http';
export interface DSpaceRESTV2Response { export interface DSpaceRESTV2Response {
payload: { payload: {
[name: string]: any; [name: string]: any;
@@ -5,5 +7,6 @@ export interface DSpaceRESTV2Response {
_links?: any; _links?: any;
page?: any; page?: any;
}, },
headers?: HttpHeaders,
statusCode: string statusCode: string
} }

View File

@@ -1,10 +1,21 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { Request } from '@angular/http'; import { Request } from '@angular/http';
import { HttpClient, HttpResponse } from '@angular/common/http' import { HttpClient, HttpHeaders, HttpParams, HttpResponse } from '@angular/common/http'
import { Observable } from 'rxjs/Observable'; import { Observable } from 'rxjs/Observable';
import { RestRequestMethod } from '../data/request.models'; import { RestRequestMethod } from '../data/request.models';
import { DSpaceRESTV2Response } from './dspace-rest-v2-response.model'; import { DSpaceRESTV2Response } from './dspace-rest-v2-response.model';
import { HttpObserve } from '@angular/common/http/src/client';
export interface HttpOptions {
body?: any;
headers?: HttpHeaders;
params?: HttpParams;
observe?: HttpObserve;
reportProgress?: boolean;
responseType?: 'arraybuffer' | 'blob' | 'json' | 'text';
withCredentials?: boolean;
}
/** /**
* Service to access DSpace's REST API * Service to access DSpace's REST API
@@ -45,9 +56,18 @@ export class DSpaceRESTv2Service {
* @return {Observable<string>} * @return {Observable<string>}
* An Observable<string> containing the response from the server * An Observable<string> containing the response from the server
*/ */
request(method: RestRequestMethod, url: string, body?: any): Observable<DSpaceRESTV2Response> { request(method: RestRequestMethod, url: string, body?: any, options?: HttpOptions): Observable<DSpaceRESTV2Response> {
return this.http.request(method, url, { body, observe: 'response' }) const requestOptions: HttpOptions = {};
.map((res) => ({ payload: res.body, statusCode: res.statusText })) requestOptions.body = body;
requestOptions.observe = 'response';
if (options && options.headers) {
requestOptions.headers = Object.assign(new HttpHeaders(), options.headers);
}
if (options && options.responseType) {
requestOptions.responseType = options.responseType;
}
return this.http.request(method, url, requestOptions)
.map((res) => ({ payload: res.body, headers: res.headers, statusCode: res.statusText }))
.catch((err) => { .catch((err) => {
console.log('Error: ', err); console.log('Error: ', err);
return Observable.throw(err); return Observable.throw(err);

View File

@@ -0,0 +1,37 @@
import { autoserialize, inheritSerialization } from 'cerialize';
import { CacheableObject } from '../../cache/object-cache.reducer';
import { ListableObject } from '../../../shared/object-collection/shared/listable-object.model';
import { NormalizedDSpaceObject } from '../../cache/models/normalized-dspace-object.model';
import { Eperson } from './eperson.model';
import { mapsTo, relationship } from '../../cache/builders/build-decorators';
import { ResourceType } from '../../shared/resource-type';
@mapsTo(Eperson)
@inheritSerialization(NormalizedDSpaceObject)
export class NormalizedEpersonModel extends NormalizedDSpaceObject implements CacheableObject, ListableObject {
@autoserialize
public handle: string;
@autoserialize
@relationship(ResourceType.Group, true)
groups: string[];
@autoserialize
public netid: string;
@autoserialize
public lastActive: string;
@autoserialize
public canLogIn: boolean;
@autoserialize
public email: string;
@autoserialize
public requireCertificate: boolean;
@autoserialize
public selfRegistered: boolean;
}

View File

@@ -0,0 +1,18 @@
import { autoserialize, inheritSerialization } from 'cerialize';
import { CacheableObject } from '../../cache/object-cache.reducer';
import { ListableObject } from '../../../shared/object-collection/shared/listable-object.model';
import { NormalizedDSpaceObject } from '../../cache/models/normalized-dspace-object.model';
import { Eperson } from './eperson.model';
import { mapsTo } from '../../cache/builders/build-decorators';
import { Group } from './group.model';
@mapsTo(Group)
@inheritSerialization(NormalizedDSpaceObject)
export class NormalizedGroupModel extends NormalizedDSpaceObject implements CacheableObject, ListableObject {
@autoserialize
public handle: string;
@autoserialize
public permanent: boolean;
}

View File

@@ -0,0 +1,22 @@
import { DSpaceObject } from '../../shared/dspace-object.model';
import { Group } from './group.model';
export class Eperson extends DSpaceObject {
public handle: string;
public groups: Group[];
public netid: string;
public lastActive: string;
public canLogIn: boolean;
public email: string;
public requireCertificate: boolean;
public selfRegistered: boolean;
}

View File

@@ -0,0 +1,8 @@
import { DSpaceObject } from '../../shared/dspace-object.model';
export class Group extends DSpaceObject {
public handle: string;
public permanent: boolean;
}

View File

@@ -0,0 +1,20 @@
import { MetadataSchema } from './metadataschema.model';
import { autoserialize } from 'cerialize';
import { ListableObject } from '../../shared/object-collection/shared/listable-object.model';
export class MetadataField implements ListableObject {
@autoserialize
self: string;
@autoserialize
element: string;
@autoserialize
qualifier: string;
@autoserialize
scopeNote: string;
@autoserialize
schema: MetadataSchema;
}

View File

@@ -0,0 +1,16 @@
import { autoserialize } from 'cerialize';
import { ListableObject } from '../../shared/object-collection/shared/listable-object.model';
export class MetadataSchema implements ListableObject {
@autoserialize
id: number;
@autoserialize
self: string;
@autoserialize
prefix: string;
@autoserialize
namespace: string;
}

View File

@@ -0,0 +1,8 @@
export class BitstreamFormat {
shortDescription: string;
description: string;
mimetype: string;
supportLevel: number;
internal: boolean;
extensions: string;
}

View File

@@ -0,0 +1,14 @@
import { autoserialize, autoserializeAs } from 'cerialize';
import { PageInfo } from '../shared/page-info.model';
import { BitstreamFormat } from '../shared/bitstream-format.model';
export class RegistryBitstreamformatsResponse {
@autoserializeAs(BitstreamFormat)
bitstreamformats: BitstreamFormat[];
@autoserialize
page: PageInfo;
@autoserialize
self: string;
}

View File

@@ -0,0 +1,14 @@
import { PageInfo } from '../shared/page-info.model';
import { autoserialize, autoserializeAs } from 'cerialize';
import { MetadataField } from '../metadata/metadatafield.model';
export class RegistryMetadatafieldsResponse {
@autoserializeAs(MetadataField)
metadatafields: MetadataField[];
@autoserialize
page: PageInfo;
@autoserialize
self: string;
}

View File

@@ -0,0 +1,14 @@
import { MetadataSchema } from '../metadata/metadataschema.model';
import { PageInfo } from '../shared/page-info.model';
import { autoserialize, autoserializeAs } from 'cerialize';
export class RegistryMetadataschemasResponse {
@autoserializeAs(MetadataSchema)
metadataschemas: MetadataSchema[];
@autoserialize
page: PageInfo;
@autoserialize
self: string;
}

View File

@@ -0,0 +1,281 @@
import { async, TestBed } from '@angular/core/testing';
import { RegistryService } from './registry.service';
import { CommonModule } from '@angular/common';
import { ResponseCacheService } from '../cache/response-cache.service';
import { RequestService } from '../data/request.service';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
import { HALEndpointService } from '../shared/hal-endpoint.service';
import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model';
import { Observable } from 'rxjs/Observable';
import { ResponseCacheEntry } from '../cache/response-cache.reducer';
import { RequestEntry } from '../data/request.reducer';
import { RemoteData } from '../data/remote-data';
import { PaginatedList } from '../data/paginated-list';
import { PageInfo } from '../shared/page-info.model';
import { GetRequest } from '../data/request.models';
import { URLCombiner } from '../url-combiner/url-combiner';
import { getMockRequestService } from '../../shared/mocks/mock-request.service';
import { getMockResponseCacheService } from '../../shared/mocks/mock-response-cache.service';
import {
RegistryBitstreamformatsSuccessResponse,
RegistryMetadatafieldsSuccessResponse, RegistryMetadataschemasSuccessResponse,
SearchSuccessResponse
} from '../cache/response-cache.models';
import { SearchQueryResponse } from '../../+search-page/search-service/search-query-response.model';
import { Component } from '@angular/core';
import { RegistryMetadataschemasResponse } from './registry-metadataschemas-response.model';
import { RegistryMetadatafieldsResponse } from './registry-metadatafields-response.model';
import { RegistryBitstreamformatsResponse } from './registry-bitstreamformats-response.model';
@Component({ template: '' })
class DummyComponent {
}
describe('RegistryService', () => {
let registryService: RegistryService;
const pagination: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), {
id: 'registry-service-spec-pagination',
pageSize: 20
});
const mockSchemasList = [
{
id: 1,
self: 'https://dspace7.4science.it/dspace-spring-rest/api/core/metadataschemas/1',
prefix: 'dc',
namespace: 'http://dublincore.org/documents/dcmi-terms/'
},
{
id: 2,
self: 'https://dspace7.4science.it/dspace-spring-rest/api/core/metadataschemas/2',
prefix: 'mock',
namespace: 'http://dspace.org/mockschema'
}
];
const mockFieldsList = [
{
self: 'https://dspace7.4science.it/dspace-spring-rest/api/core/metadatafields/8',
element: 'contributor',
qualifier: 'advisor',
scopenote: null,
schema: mockSchemasList[0]
},
{
self: 'https://dspace7.4science.it/dspace-spring-rest/api/core/metadatafields/9',
element: 'contributor',
qualifier: 'author',
scopenote: null,
schema: mockSchemasList[0]
},
{
self: 'https://dspace7.4science.it/dspace-spring-rest/api/core/metadatafields/10',
element: 'contributor',
qualifier: 'editor',
scopenote: 'test scope note',
schema: mockSchemasList[1]
},
{
self: 'https://dspace7.4science.it/dspace-spring-rest/api/core/metadatafields/11',
element: 'contributor',
qualifier: 'illustrator',
scopenote: null,
schema: mockSchemasList[1]
}
];
const mockFormatsList = [
{
shortDescription: 'Unknown',
description: 'Unknown data format',
mimetype: 'application/octet-stream',
supportLevel: 0,
internal: false,
extensions: null
},
{
shortDescription: 'License',
description: 'Item-specific license agreed upon to submission',
mimetype: 'text/plain; charset=utf-8',
supportLevel: 1,
internal: true,
extensions: null
},
{
shortDescription: 'CC License',
description: 'Item-specific Creative Commons license agreed upon to submission',
mimetype: 'text/html; charset=utf-8',
supportLevel: 2,
internal: true,
extensions: null
},
{
shortDescription: 'Adobe PDF',
description: 'Adobe Portable Document Format',
mimetype: 'application/pdf',
supportLevel: 0,
internal: false,
extensions: null
}
];
const pageInfo = new PageInfo();
pageInfo.elementsPerPage = 20;
pageInfo.currentPage = 1;
const endpoint = 'path';
const endpointWithParams = `${endpoint}?size=${pageInfo.elementsPerPage}&page=${pageInfo.currentPage - 1}`;
const halServiceStub = {
getEndpoint: (link: string) => Observable.of(endpoint)
};
const rdbStub = {
toRemoteDataObservable: (requestEntryObs: Observable<RequestEntry>, responseCacheObs: Observable<ResponseCacheEntry>, payloadObs: Observable<any>) => {
return Observable.combineLatest(requestEntryObs,
responseCacheObs, payloadObs, (req, res, pay) => {
return { req, res, pay };
});
},
aggregate: (input: Array<Observable<RemoteData<any>>>): Observable<RemoteData<any[]>> => {
return Observable.of(new RemoteData(false, false, true, null, []));
}
};
beforeEach(() => {
TestBed.configureTestingModule({
imports: [ CommonModule ],
declarations: [
DummyComponent
],
providers: [
{ provide: ResponseCacheService, useValue: getMockResponseCacheService() },
{ provide: RequestService, useValue: getMockRequestService() },
{ provide: RemoteDataBuildService, useValue: rdbStub },
{ provide: HALEndpointService, useValue: halServiceStub },
RegistryService
]
});
registryService = TestBed.get(RegistryService);
spyOn((registryService as any).halService, 'getEndpoint').and.returnValue(Observable.of(endpoint));
});
describe('when requesting metadataschemas', () => {
const queryResponse = Object.assign(new RegistryMetadataschemasResponse(), { metadataschemas: mockSchemasList, page: pageInfo });
const response = new RegistryMetadataschemasSuccessResponse(queryResponse, '200', pageInfo);
const responseEntry = Object.assign(new ResponseCacheEntry(), { response: response });
beforeEach(() => {
(registryService as any).responseCache.get.and.returnValue(Observable.of(responseEntry));
/* tslint:disable:no-empty */
registryService.getMetadataSchemas(pagination).subscribe((value) => {
});
/* tslint:enable:no-empty */
});
it('should call getEndpoint on the halService', () => {
expect((registryService as any).halService.getEndpoint).toHaveBeenCalled();
});
it('should send out the request on the request service', () => {
expect((registryService as any).requestService.configure).toHaveBeenCalled();
});
it('should call getByHref on the request service with the correct request url', () => {
expect((registryService as any).requestService.getByHref).toHaveBeenCalledWith(endpointWithParams);
});
it('should call get on the request service with the correct request url', () => {
expect((registryService as any).responseCache.get).toHaveBeenCalledWith(endpointWithParams);
});
});
describe('when requesting metadataschema by name', () => {
const queryResponse = Object.assign(new RegistryMetadataschemasResponse(), { metadataschemas: mockSchemasList, page: pageInfo });
const response = new RegistryMetadataschemasSuccessResponse(queryResponse, '200', pageInfo);
const responseEntry = Object.assign(new ResponseCacheEntry(), { response: response });
beforeEach(() => {
(registryService as any).responseCache.get.and.returnValue(Observable.of(responseEntry));
/* tslint:disable:no-empty */
registryService.getMetadataSchemaByName(mockSchemasList[0].prefix).subscribe((value) => {
});
/* tslint:enable:no-empty */
});
it('should call getEndpoint on the halService', () => {
expect((registryService as any).halService.getEndpoint).toHaveBeenCalled();
});
it('should send out the request on the request service', () => {
expect((registryService as any).requestService.configure).toHaveBeenCalled();
});
it('should call getByHref on the request service with the correct request url', () => {
expect((registryService as any).requestService.getByHref.calls.argsFor(0)[0]).toContain(endpoint);
});
it('should call get on the request service with the correct request url', () => {
expect((registryService as any).responseCache.get.calls.argsFor(0)[0]).toContain(endpoint);
});
});
describe('when requesting metadatafields', () => {
const queryResponse = Object.assign(new RegistryMetadatafieldsResponse(), { metadatafields: mockFieldsList, page: pageInfo });
const response = new RegistryMetadatafieldsSuccessResponse(queryResponse, '200', pageInfo);
const responseEntry = Object.assign(new ResponseCacheEntry(), { response: response });
beforeEach(() => {
(registryService as any).responseCache.get.and.returnValue(Observable.of(responseEntry));
/* tslint:disable:no-empty */
registryService.getMetadataFieldsBySchema(mockSchemasList[0], pagination).subscribe((value) => {
});
/* tslint:enable:no-empty */
});
it('should call getEndpoint on the halService', () => {
expect((registryService as any).halService.getEndpoint).toHaveBeenCalled();
});
it('should send out the request on the request service', () => {
expect((registryService as any).requestService.configure).toHaveBeenCalled();
});
it('should call getByHref on the request service with the correct request url', () => {
expect((registryService as any).requestService.getByHref).toHaveBeenCalledWith(endpointWithParams);
});
it('should call get on the request service with the correct request url', () => {
expect((registryService as any).responseCache.get).toHaveBeenCalledWith(endpointWithParams);
});
});
describe('when requesting bitstreamformats', () => {
const queryResponse = Object.assign(new RegistryBitstreamformatsResponse(), { bitstreamformats: mockFieldsList, page: pageInfo });
const response = new RegistryBitstreamformatsSuccessResponse(queryResponse, '200', pageInfo);
const responseEntry = Object.assign(new ResponseCacheEntry(), { response: response });
beforeEach(() => {
(registryService as any).responseCache.get.and.returnValue(Observable.of(responseEntry));
/* tslint:disable:no-empty */
registryService.getBitstreamFormats(pagination).subscribe((value) => {
});
/* tslint:enable:no-empty */
});
it('should call getEndpoint on the halService', () => {
expect((registryService as any).halService.getEndpoint).toHaveBeenCalled();
});
it('should send out the request on the request service', () => {
expect((registryService as any).requestService.configure).toHaveBeenCalled();
});
it('should call getByHref on the request service with the correct request url', () => {
expect((registryService as any).requestService.getByHref).toHaveBeenCalledWith(endpointWithParams);
});
it('should call get on the request service with the correct request url', () => {
expect((registryService as any).responseCache.get).toHaveBeenCalledWith(endpointWithParams);
});
});
});

View File

@@ -0,0 +1,234 @@
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs/Observable';
import { RemoteData } from '../data/remote-data';
import { PaginatedList } from '../data/paginated-list';
import { PageInfo } from '../shared/page-info.model';
import { MetadataSchema } from '../metadata/metadataschema.model';
import { MetadataField } from '../metadata/metadatafield.model';
import { BitstreamFormat } from './mock-bitstream-format.model';
import { flatMap, map, tap } from 'rxjs/operators';
import { GetRequest, RestRequest } from '../data/request.models';
import { GenericConstructor } from '../shared/generic-constructor';
import { ResponseParsingService } from '../data/parsing.service';
import { RegistryMetadataschemasResponseParsingService } from '../data/registry-metadataschemas-response-parsing.service';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
import { RequestService } from '../data/request.service';
import { ResponseCacheService } from '../cache/response-cache.service';
import { RegistryMetadataschemasResponse } from './registry-metadataschemas-response.model';
import { ResponseCacheEntry } from '../cache/response-cache.reducer';
import {
MetadataschemaSuccessResponse, RegistryBitstreamformatsSuccessResponse, RegistryMetadatafieldsSuccessResponse,
RegistryMetadataschemasSuccessResponse
} from '../cache/response-cache.models';
import { HALEndpointService } from '../shared/hal-endpoint.service';
import { MetadataschemaParsingService } from '../data/metadataschema-parsing.service';
import { Res } from 'awesome-typescript-loader/dist/checker/protocol';
import { RegistryMetadatafieldsResponseParsingService } from '../data/registry-metadatafields-response-parsing.service';
import { RegistryMetadatafieldsResponse } from './registry-metadatafields-response.model';
import { isNotEmpty } from '../../shared/empty.util';
import { URLCombiner } from '../url-combiner/url-combiner';
import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model';
import { RegistryBitstreamformatsResponseParsingService } from '../data/registry-bitstreamformats-response-parsing.service';
import { RegistryBitstreamformatsResponse } from './registry-bitstreamformats-response.model';
@Injectable()
export class RegistryService {
private metadataSchemasPath = 'metadataschemas';
private metadataFieldsPath = 'metadatafields';
private bitstreamFormatsPath = 'bitstreamformats';
constructor(protected responseCache: ResponseCacheService,
protected requestService: RequestService,
private rdb: RemoteDataBuildService,
private halService: HALEndpointService) {
}
public getMetadataSchemas(pagination: PaginationComponentOptions): Observable<RemoteData<PaginatedList<MetadataSchema>>> {
const requestObs = this.getMetadataSchemasRequestObs(pagination);
const requestEntryObs = requestObs.pipe(
flatMap((request: RestRequest) => this.requestService.getByHref(request.href))
);
const responseCacheObs = requestObs.pipe(
flatMap((request: RestRequest) => this.responseCache.get(request.href))
);
const rmrObs: Observable<RegistryMetadataschemasResponse> = responseCacheObs.pipe(
map((entry: ResponseCacheEntry) => entry.response),
map((response: RegistryMetadataschemasSuccessResponse) => response.metadataschemasResponse)
);
const metadataschemasObs: Observable<MetadataSchema[]> = rmrObs.pipe(
map((rmr: RegistryMetadataschemasResponse) => rmr.metadataschemas)
);
const pageInfoObs: Observable<PageInfo> = responseCacheObs.pipe(
map((entry: ResponseCacheEntry) => entry.response),
map((response: RegistryMetadataschemasSuccessResponse) => response.pageInfo)
);
const payloadObs = Observable.combineLatest(metadataschemasObs, pageInfoObs, (metadataschemas, pageInfo) => {
return new PaginatedList(pageInfo, metadataschemas);
});
return this.rdb.toRemoteDataObservable(requestEntryObs, responseCacheObs, payloadObs);
}
public getMetadataSchemaByName(schemaName: string): Observable<RemoteData<MetadataSchema>> {
// Temporary pagination to get ALL metadataschemas until there's a rest api endpoint for fetching a specific schema
const pagination: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), {
id: 'all-metadatafields-pagination',
pageSize: 10000
});
const requestObs = this.getMetadataSchemasRequestObs(pagination);
const requestEntryObs = requestObs.pipe(
flatMap((request: RestRequest) => this.requestService.getByHref(request.href))
);
const responseCacheObs = requestObs.pipe(
flatMap((request: RestRequest) => this.responseCache.get(request.href))
);
const rmrObs: Observable<RegistryMetadataschemasResponse> = responseCacheObs.pipe(
map((entry: ResponseCacheEntry) => entry.response),
map((response: RegistryMetadataschemasSuccessResponse) => response.metadataschemasResponse)
);
const metadataschemaObs: Observable<MetadataSchema> = rmrObs.pipe(
map((rmr: RegistryMetadataschemasResponse) => rmr.metadataschemas),
map((metadataSchemas: MetadataSchema[]) => metadataSchemas.filter((value) => value.prefix === schemaName)[0])
);
return this.rdb.toRemoteDataObservable(requestEntryObs, responseCacheObs, metadataschemaObs);
}
public getMetadataFieldsBySchema(schema: MetadataSchema, pagination: PaginationComponentOptions): Observable<RemoteData<PaginatedList<MetadataField>>> {
const requestObs = this.getMetadataFieldsRequestObs(pagination);
const requestEntryObs = requestObs.pipe(
flatMap((request: RestRequest) => this.requestService.getByHref(request.href))
);
const responseCacheObs = requestObs.pipe(
flatMap((request: RestRequest) => this.responseCache.get(request.href))
);
const rmrObs: Observable<RegistryMetadatafieldsResponse> = responseCacheObs.pipe(
map((entry: ResponseCacheEntry) => entry.response),
map((response: RegistryMetadatafieldsSuccessResponse) => response.metadatafieldsResponse)
);
const metadatafieldsObs: Observable<MetadataField[]> = rmrObs.pipe(
map((rmr: RegistryMetadatafieldsResponse) => rmr.metadatafields),
map((metadataFields: MetadataField[]) => metadataFields.filter((field) => field.schema.id === schema.id))
);
const pageInfoObs: Observable<PageInfo> = responseCacheObs.pipe(
map((entry: ResponseCacheEntry) => entry.response),
map((response: RegistryMetadatafieldsSuccessResponse) => response.pageInfo)
);
const payloadObs = Observable.combineLatest(metadatafieldsObs, pageInfoObs, (metadatafields, pageInfo) => {
return new PaginatedList(pageInfo, metadatafields);
});
return this.rdb.toRemoteDataObservable(requestEntryObs, responseCacheObs, payloadObs);
}
public getBitstreamFormats(pagination: PaginationComponentOptions): Observable<RemoteData<PaginatedList<BitstreamFormat>>> {
const requestObs = this.getBitstreamFormatsRequestObs(pagination);
const requestEntryObs = requestObs.pipe(
flatMap((request: RestRequest) => this.requestService.getByHref(request.href))
);
const responseCacheObs = requestObs.pipe(
flatMap((request: RestRequest) => this.responseCache.get(request.href))
);
const rbrObs: Observable<RegistryBitstreamformatsResponse> = responseCacheObs.pipe(
map((entry: ResponseCacheEntry) => entry.response),
map((response: RegistryBitstreamformatsSuccessResponse) => response.bitstreamformatsResponse)
);
const bitstreamformatsObs: Observable<BitstreamFormat[]> = rbrObs.pipe(
map((rbr: RegistryBitstreamformatsResponse) => rbr.bitstreamformats)
);
const pageInfoObs: Observable<PageInfo> = responseCacheObs.pipe(
map((entry: ResponseCacheEntry) => entry.response),
map((response: RegistryBitstreamformatsSuccessResponse) => response.pageInfo)
);
const payloadObs = Observable.combineLatest(bitstreamformatsObs, pageInfoObs, (bitstreamformats, pageInfo) => {
return new PaginatedList(pageInfo, bitstreamformats);
});
return this.rdb.toRemoteDataObservable(requestEntryObs, responseCacheObs, payloadObs);
}
private getMetadataSchemasRequestObs(pagination: PaginationComponentOptions): Observable<RestRequest> {
return this.halService.getEndpoint(this.metadataSchemasPath).pipe(
map((url: string) => {
const args: string[] = [];
args.push(`size=${pagination.pageSize}`);
args.push(`page=${pagination.currentPage - 1}`);
if (isNotEmpty(args)) {
url = new URLCombiner(url, `?${args.join('&')}`).toString();
}
const request = new GetRequest(this.requestService.generateRequestId(), url);
return Object.assign(request, {
getResponseParser(): GenericConstructor<ResponseParsingService> {
return RegistryMetadataschemasResponseParsingService;
}
});
}),
tap((request: RestRequest) => this.requestService.configure(request)),
);
}
private getMetadataFieldsRequestObs(pagination: PaginationComponentOptions): Observable<RestRequest> {
return this.halService.getEndpoint(this.metadataFieldsPath).pipe(
map((url: string) => {
const args: string[] = [];
args.push(`size=${pagination.pageSize}`);
args.push(`page=${pagination.currentPage - 1}`);
if (isNotEmpty(args)) {
url = new URLCombiner(url, `?${args.join('&')}`).toString();
}
const request = new GetRequest(this.requestService.generateRequestId(), url);
return Object.assign(request, {
getResponseParser(): GenericConstructor<ResponseParsingService> {
return RegistryMetadatafieldsResponseParsingService;
}
});
}),
tap((request: RestRequest) => this.requestService.configure(request)),
);
}
private getBitstreamFormatsRequestObs(pagination: PaginationComponentOptions): Observable<RestRequest> {
return this.halService.getEndpoint(this.bitstreamFormatsPath).pipe(
map((url: string) => {
const args: string[] = [];
args.push(`size=${pagination.pageSize}`);
args.push(`page=${pagination.currentPage - 1}`);
if (isNotEmpty(args)) {
url = new URLCombiner(url, `?${args.join('&')}`).toString();
}
const request = new GetRequest(this.requestService.generateRequestId(), url);
return Object.assign(request, {
getResponseParser(): GenericConstructor<ResponseParsingService> {
return RegistryBitstreamformatsResponseParsingService;
}
});
}),
tap((request: RestRequest) => this.requestService.configure(request)),
);
}
}

View File

@@ -6,4 +6,6 @@ export enum ResourceType {
Item = 'item', Item = 'item',
Collection = 'collection', Collection = 'collection',
Community = 'community', Community = 'community',
Eperson = 'eperson',
Group = 'group',
} }

View File

@@ -12,6 +12,7 @@
<a class="nav-link" routerLink="/home" routerLinkActive="active"><i class="fa fa-home fa-fw" aria-hidden="true"></i> {{ 'nav.home' | translate }}<span class="sr-only">(current)</span></a> <a class="nav-link" routerLink="/home" routerLinkActive="active"><i class="fa fa-home fa-fw" aria-hidden="true"></i> {{ 'nav.home' | translate }}<span class="sr-only">(current)</span></a>
</li> </li>
</ul> </ul>
<ds-auth-nav-menu></ds-auth-nav-menu>
</div> </div>
</nav> </nav>
</header> </header>

View File

@@ -9,6 +9,16 @@ import { Observable } from 'rxjs/Observable';
import { HeaderComponent } from './header.component'; import { HeaderComponent } from './header.component';
import { HeaderState } from './header.reducer'; import { HeaderState } from './header.reducer';
import { HeaderToggleAction } from './header.actions'; import { HeaderToggleAction } from './header.actions';
import { AuthNavMenuComponent } from '../shared/auth-nav-menu/auth-nav-menu.component';
import { LogInComponent } from '../shared/log-in/log-in.component';
import { LogOutComponent } from '../shared/log-out/log-out.component';
import { LoadingComponent } from '../shared/loading/loading.component';
import { ReactiveFormsModule } from '@angular/forms';
import { HostWindowService } from '../shared/host-window.service';
import { HostWindowServiceStub } from '../shared/testing/host-window-service-stub';
import { RouterStub } from '../shared/testing/router-stub';
import { Router } from '@angular/router';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
let comp: HeaderComponent; let comp: HeaderComponent;
let fixture: ComponentFixture<HeaderComponent>; let fixture: ComponentFixture<HeaderComponent>;
@@ -19,8 +29,17 @@ describe('HeaderComponent', () => {
// async beforeEach // async beforeEach
beforeEach(async(() => { beforeEach(async(() => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
imports: [StoreModule.forRoot({}), TranslateModule.forRoot(), NgbCollapseModule.forRoot()], imports: [
declarations: [HeaderComponent] StoreModule.forRoot({}),
TranslateModule.forRoot(),
NgbCollapseModule.forRoot(),
NoopAnimationsModule,
ReactiveFormsModule],
declarations: [HeaderComponent, AuthNavMenuComponent, LoadingComponent, LogInComponent, LogOutComponent],
providers: [
{ provide: HostWindowService, useValue: new HostWindowServiceStub(800) },
{ provide: Router, useClass: RouterStub },
]
}) })
.compileComponents(); // compile template and css .compileComponents(); // compile template and css
})); }));

View File

@@ -1,10 +1,12 @@
import { Component, OnInit } from '@angular/core'; import { Component, OnInit } from '@angular/core';
import { createSelector, Store } from '@ngrx/store'; import { createSelector, Store } from '@ngrx/store';
import { Observable } from 'rxjs/Observable'; import { Observable } from 'rxjs/Observable';
import { RouterReducerState } from '@ngrx/router-store';
import { HeaderState } from './header.reducer'; import { HeaderState } from './header.reducer';
import { HeaderToggleAction } from './header.actions'; import { HeaderToggleAction } from './header.actions';
import { AppState } from '../app.reducer'; import { AppState } from '../app.reducer';
import { HostWindowService } from '../shared/host-window.service';
const headerStateSelector = (state: AppState) => state.header; const headerStateSelector = (state: AppState) => state.header;
const navCollapsedSelector = createSelector(headerStateSelector, (header: HeaderState) => header.navCollapsed); const navCollapsedSelector = createSelector(headerStateSelector, (header: HeaderState) => header.navCollapsed);
@@ -12,17 +14,25 @@ const navCollapsedSelector = createSelector(headerStateSelector, (header: Header
@Component({ @Component({
selector: 'ds-header', selector: 'ds-header',
styleUrls: ['header.component.scss'], styleUrls: ['header.component.scss'],
templateUrl: 'header.component.html' templateUrl: 'header.component.html',
}) })
export class HeaderComponent implements OnInit { export class HeaderComponent implements OnInit {
/**
* Whether user is authenticated.
* @type {Observable<string>}
*/
public isAuthenticated: Observable<boolean>;
public isNavBarCollapsed: Observable<boolean>; public isNavBarCollapsed: Observable<boolean>;
public showAuth = false;
constructor( constructor(
private store: Store<AppState> private store: Store<AppState>,
private windowService: HostWindowService
) { ) {
} }
ngOnInit(): void { ngOnInit(): void {
// set loading
this.isNavBarCollapsed = this.store.select(navCollapsedSelector); this.isNavBarCollapsed = this.store.select(navCollapsedSelector);
} }

View File

@@ -1,4 +1,4 @@
import { ServerResponseService } from '../shared/server-response.service'; import { ServerResponseService } from '../shared/services/server-response.service';
import { Component, ChangeDetectionStrategy } from '@angular/core'; import { Component, ChangeDetectionStrategy } from '@angular/core';
@Component({ @Component({

View File

@@ -0,0 +1,26 @@
<ul class="navbar-nav" [ngClass]="{'mr-auto': (isXsOrSm$ | async)}">
<li *ngIf="!(isAuthenticated | async) && !(isXsOrSm$ | async) && (showAuth | async)" class="nav-item dropdown" (click)="$event.stopPropagation();">
<div ngbDropdown placement="bottom-right" class="d-inline-block float-right" @fadeInOut>
<a href="#" id="dropdownLogin" class="nav-link" (click)="$event.preventDefault()" ngbDropdownToggle><i class="fa fa-sign-in fa-fw" aria-hidden="true"></i> {{ 'nav.login' | translate }}<span class="caret"></span></a>
<div id="loginDropdownMenu" [ngClass]="{'pl-3 pr-3': (loading | async)}" ngbDropdownMenu aria-labelledby="dropdownLogin">
<ds-log-in></ds-log-in>
</div>
</div>
</li>
<li *ngIf="!(isAuthenticated | async) && (isXsOrSm$ | async)" class="nav-item">
<a id="loginLink" class="nav-link" routerLink="/login" routerLinkActive="active"><i class="fa fa-sign-in fa-fw" aria-hidden="true"></i> {{ 'nav.login' | translate }}<span class="sr-only">(current)</span></a>
</li>
<li *ngIf="(isAuthenticated | async) && !(isXsOrSm$ | async) && (showAuth | async)" class="nav-item">
<div ngbDropdown placement="bottom-right" class="d-inline-block" [ngClass]="{'float-right': !(isXsOrSm$ | async)}" @fadeInOut>
<a href="#" id="dropdownUser" class="nav-link" (click)="$event.preventDefault()" ngbDropdownToggle><i class="fa fa-user fa-fw" aria-hidden="true"></i>Hello {{(user | async).name}}<span class="caret"></span></a>
<div id="logoutDropdownMenu" ngbDropdownMenu aria-labelledby="dropdownUser">
<ds-log-out></ds-log-out>
</div>
</div>
</li>
<li *ngIf="(isAuthenticated | async) && (isXsOrSm$ | async)" class="nav-item">
<a id="logoutLink" class="nav-link" routerLink="/logout" routerLinkActive="active"><i class="fa fa-sign-out fa-fw" aria-hidden="true"></i> {{ 'nav.logout' | translate }}<span class="sr-only">(current)</span></a>
</li>
</ul>

View File

@@ -0,0 +1,8 @@
#loginDropdownMenu, #logoutDropdownMenu {
min-width: 330px;
z-index: 1002;
}
#loginDropdownMenu {
min-height: 260px;
}

View File

@@ -0,0 +1,297 @@
import { CUSTOM_ELEMENTS_SCHEMA, DebugElement } from '@angular/core';
import { async, ComponentFixture, inject, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { Store, StoreModule } from '@ngrx/store';
import { authReducer, AuthState } from '../../core/auth/auth.reducer';
import { EpersonMock } from '../testing/eperson-mock';
import { TranslateModule } from '@ngx-translate/core';
import { AppState } from '../../app.reducer';
import { AuthNavMenuComponent } from './auth-nav-menu.component';
import { HostWindowServiceStub } from '../testing/host-window-service-stub';
import { HostWindowService } from '../host-window.service';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { AuthTokenInfo } from '../../core/auth/models/auth-token-info.model';
describe('AuthNavMenuComponent', () => {
let component: AuthNavMenuComponent;
let deNavMenu: DebugElement;
let deNavMenuItem: DebugElement;
let fixture: ComponentFixture<AuthNavMenuComponent>;
const notAuthState: AuthState = {
authenticated: false,
loaded: false,
loading: false
};
const authState: AuthState = {
authenticated: true,
loaded: true,
loading: false,
authToken: new AuthTokenInfo('test_token'),
user: EpersonMock
};
let routerState = {
url: '/home'
};
describe('when is a not mobile view', () => {
beforeEach(async(() => {
const window = new HostWindowServiceStub(800);
// refine the test module by declaring the test component
TestBed.configureTestingModule({
imports: [
NoopAnimationsModule,
StoreModule.forRoot(authReducer),
TranslateModule.forRoot()
],
declarations: [
AuthNavMenuComponent
],
providers: [
{provide: HostWindowService, useValue: window},
],
schemas: [
CUSTOM_ELEMENTS_SCHEMA
]
})
.compileComponents();
}));
describe('when route is /login and user is not authenticated', () => {
routerState = {
url: '/login'
};
beforeEach(inject([Store], (store: Store<AppState>) => {
store
.subscribe((state) => {
(state as any).router = Object.create({});
(state as any).router.state = routerState;
(state as any).core = Object.create({});
(state as any).core.auth = notAuthState;
});
// create component and test fixture
fixture = TestBed.createComponent(AuthNavMenuComponent);
// get test component from the fixture
component = fixture.componentInstance;
fixture.detectChanges();
const navMenuSelector = '.navbar-nav';
deNavMenu = fixture.debugElement.query(By.css(navMenuSelector));
const navMenuItemSelector = 'li';
deNavMenuItem = deNavMenu.query(By.css(navMenuItemSelector));
}));
it('should not render', () => {
expect(component).toBeTruthy();
expect(deNavMenu.nativeElement).toBeDefined();
expect(deNavMenuItem).toBeNull();
});
});
describe('when route is /logout and user is authenticated', () => {
routerState = {
url: '/logout'
};
beforeEach(inject([Store], (store: Store<AppState>) => {
store
.subscribe((state) => {
(state as any).router = Object.create({});
(state as any).router.state = routerState;
(state as any).core = Object.create({});
(state as any).core.auth = authState;
});
// create component and test fixture
fixture = TestBed.createComponent(AuthNavMenuComponent);
// get test component from the fixture
component = fixture.componentInstance;
fixture.detectChanges();
const navMenuSelector = '.navbar-nav';
deNavMenu = fixture.debugElement.query(By.css(navMenuSelector));
const navMenuItemSelector = 'li';
deNavMenuItem = deNavMenu.query(By.css(navMenuItemSelector));
}));
it('should not render', () => {
expect(component).toBeTruthy();
expect(deNavMenu.nativeElement).toBeDefined();
expect(deNavMenuItem).toBeNull();
});
});
describe('when route is not /login neither /logout', () => {
describe('when user is not authenticated', () => {
beforeEach(inject([Store], (store: Store<AppState>) => {
routerState = {
url: '/home'
};
store
.subscribe((state) => {
(state as any).router = Object.create({});
(state as any).router.state = routerState;
(state as any).core = Object.create({});
(state as any).core.auth = notAuthState;
});
// create component and test fixture
fixture = TestBed.createComponent(AuthNavMenuComponent);
// get test component from the fixture
component = fixture.componentInstance;
fixture.detectChanges();
const navMenuSelector = '.navbar-nav';
deNavMenu = fixture.debugElement.query(By.css(navMenuSelector));
const navMenuItemSelector = 'li';
deNavMenuItem = deNavMenu.query(By.css(navMenuItemSelector));
}));
it('should render login dropdown menu', () => {
const loginDropdownMenu = deNavMenuItem.query(By.css('div[id=loginDropdownMenu]'));
expect(loginDropdownMenu.nativeElement).toBeDefined();
});
});
describe('when user is authenticated', () => {
beforeEach(inject([Store], (store: Store<AppState>) => {
routerState = {
url: '/home'
};
store
.subscribe((state) => {
(state as any).router = Object.create({});
(state as any).router.state = routerState;
(state as any).core = Object.create({});
(state as any).core.auth = authState;
});
// create component and test fixture
fixture = TestBed.createComponent(AuthNavMenuComponent);
// get test component from the fixture
component = fixture.componentInstance;
fixture.detectChanges();
const navMenuSelector = '.navbar-nav';
deNavMenu = fixture.debugElement.query(By.css(navMenuSelector));
const navMenuItemSelector = 'li';
deNavMenuItem = deNavMenu.query(By.css(navMenuItemSelector));
}));
it('should render logout dropdown menu', () => {
const logoutDropdownMenu = deNavMenuItem.query(By.css('div[id=logoutDropdownMenu]'));
expect(logoutDropdownMenu.nativeElement).toBeDefined();
});
})
})
});
describe('when is a mobile view', () => {
beforeEach(async(() => {
const window = new HostWindowServiceStub(300);
// refine the test module by declaring the test component
TestBed.configureTestingModule({
imports: [
NoopAnimationsModule,
StoreModule.forRoot(authReducer),
TranslateModule.forRoot()
],
declarations: [
AuthNavMenuComponent
],
providers: [
{provide: HostWindowService, useValue: window},
],
schemas: [
CUSTOM_ELEMENTS_SCHEMA
]
})
.compileComponents();
}));
describe('when user is not authenticated', () => {
beforeEach(inject([Store], (store: Store<AppState>) => {
store
.subscribe((state) => {
(state as any).router = Object.create({});
(state as any).router.state = routerState;
(state as any).core = Object.create({});
(state as any).core.auth = notAuthState;
});
// create component and test fixture
fixture = TestBed.createComponent(AuthNavMenuComponent);
// get test component from the fixture
component = fixture.componentInstance;
fixture.detectChanges();
const navMenuSelector = '.navbar-nav';
deNavMenu = fixture.debugElement.query(By.css(navMenuSelector));
const navMenuItemSelector = 'li';
deNavMenuItem = deNavMenu.query(By.css(navMenuItemSelector));
}));
it('should render login link', () => {
const loginDropdownMenu = deNavMenuItem.query(By.css('a[id=loginLink]'));
expect(loginDropdownMenu.nativeElement).toBeDefined();
});
});
describe('when user is authenticated', () => {
beforeEach(inject([Store], (store: Store<AppState>) => {
store
.subscribe((state) => {
(state as any).router = Object.create({});
(state as any).router.state = routerState;
(state as any).core = Object.create({});
(state as any).core.auth = authState;
});
// create component and test fixture
fixture = TestBed.createComponent(AuthNavMenuComponent);
// get test component from the fixture
component = fixture.componentInstance;
fixture.detectChanges();
const navMenuSelector = '.navbar-nav';
deNavMenu = fixture.debugElement.query(By.css(navMenuSelector));
const navMenuItemSelector = 'li';
deNavMenuItem = deNavMenu.query(By.css(navMenuItemSelector));
}));
it('should render logout link', inject([Store], (store: Store<AppState>) => {
const logoutDropdownMenu = deNavMenuItem.query(By.css('a[id=logoutLink]'));
expect(logoutDropdownMenu.nativeElement).toBeDefined();
}));
})
})
});

View File

@@ -0,0 +1,59 @@
import { Component, OnInit } from '@angular/core';
import { Observable } from 'rxjs/Observable';
import { RouterReducerState } from '@ngrx/router-store';
import { Store } from '@ngrx/store';
import { fadeInOut, fadeOut } from '../animations/fade';
import { HostWindowService } from '../host-window.service';
import { AppState, routerStateSelector } from '../../app.reducer';
import { isNotUndefined } from '../empty.util';
import { getAuthenticatedUser, isAuthenticated, isAuthenticationLoading } from '../../core/auth/selectors';
import { Eperson } from '../../core/eperson/models/eperson.model';
import { LOGIN_ROUTE, LOGOUT_ROUTE } from '../../core/auth/auth.service';
@Component({
selector: 'ds-auth-nav-menu',
templateUrl: './auth-nav-menu.component.html',
styleUrls: ['./auth-nav-menu.component.scss'],
animations: [fadeInOut, fadeOut]
})
export class AuthNavMenuComponent implements OnInit {
/**
* Whether user is authenticated.
* @type {Observable<string>}
*/
public isAuthenticated: Observable<boolean>;
/**
* True if the authentication is loading.
* @type {boolean}
*/
public loading: Observable<boolean>;
public isXsOrSm$: Observable<boolean>;
public showAuth = Observable.of(false);
public user: Observable<Eperson>;
constructor(private store: Store<AppState>,
private windowService: HostWindowService) {
this.isXsOrSm$ = this.windowService.isXsOrSm();
}
ngOnInit(): void {
// set isAuthenticated
this.isAuthenticated = this.store.select(isAuthenticated);
// set loading
this.loading = this.store.select(isAuthenticationLoading);
this.user = this.store.select(getAuthenticatedUser);
this.showAuth = this.store.select(routerStateSelector)
.filter((router: RouterReducerState) => isNotUndefined(router) && isNotUndefined(router.state))
.map((router: RouterReducerState) => {
return !router.state.url.startsWith(LOGIN_ROUTE) && !router.state.url.startsWith(LOGOUT_ROUTE);
});
}
}

View File

@@ -92,4 +92,12 @@ export class HostWindowService {
distinctUntilChanged() distinctUntilChanged()
); );
} }
isXsOrSm(): Observable<boolean> {
return Observable.combineLatest(
this.isXs(),
this.isSm(),
((isXs, isSm) => isXs || isSm)
).distinctUntilChanged();
}
} }

View File

@@ -0,0 +1,28 @@
<ds-loading *ngIf="(loading | async) || (isAuthenticated | async)" class="m-5"></ds-loading>
<form *ngIf="!(loading | async) && !(isAuthenticated | async)" class="form-login px-4 py-3" (ngSubmit)="submit()" [formGroup]="form" novalidate>
<label for="inputEmail" class="sr-only">{{"login.form.email" | translate}}</label>
<input id="inputEmail"
autocomplete="off"
autofocus
class="form-control form-control-lg position-relative"
formControlName="email"
placeholder="{{'login.form.email' | translate}}"
required
type="email">
<label for="inputPassword" class="sr-only">{{"login.form.password" | translate}}</label>
<input id="inputPassword"
autocomplete="off"
class="form-control form-control-lg position-relative mb-3"
placeholder="{{'login.form.password' | translate}}"
formControlName="password"
required
type="password">
<div *ngIf="(error | async) && hasError" class="alert alert-danger" role="alert" @fadeOut>{{ (error | async) | translate }}</div>
<div *ngIf="(message | async) && hasMessage" class="alert alert-info" role="alert" @fadeOut>{{ (message | async) | translate }}</div>
<button class="btn btn-lg btn-primary btn-block mt-3" type="submit" [disabled]="!form.valid">{{"login.form.submit" | translate}}</button>
<div class="dropdown-divider"></div>
<a class="dropdown-item" href="#">{{"login.form.new-user" | translate}}</a>
<a class="dropdown-item" href="#">{{"login.form.forgot-password" | translate}}</a>
</form>

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