diff --git a/README.md b/README.md index 285f8829fd..8f2320dbf3 100644 --- a/README.md +++ b/README.md @@ -23,9 +23,6 @@ git clone https://github.com/DSpace/dspace-angular.git # change directory to our repo cd dspace-angular -# install the global dependencies -yarn run global - # install the local dependencies yarn install diff --git a/package.json b/package.json index 69744b9729..7ded007e83 100644 --- a/package.json +++ b/package.json @@ -104,8 +104,10 @@ "font-awesome": "4.7.0", "http-server": "0.11.1", "https": "1.0.0", + "js-cookie": "2.2.0", "js.clone": "0.0.3", "jsonschema": "1.2.2", + "jwt-decode": "^2.2.0", "methods": "1.1.2", "morgan": "1.9.0", "ng2-file-upload": "1.2.1", @@ -133,6 +135,7 @@ "@types/express-serve-static-core": "4.11.1", "@types/hammerjs": "2.0.35", "@types/jasmine": "^2.8.6", + "@types/js-cookie": "2.1.0", "@types/memory-cache": "0.2.0", "@types/mime": "2.0.0", "@types/node": "^9.4.6", diff --git a/resources/i18n/en.json b/resources/i18n/en.json index a8a45c5dd2..ba70b87e12 100644 --- a/resources/i18n/en.json +++ b/resources/i18n/en.json @@ -46,7 +46,9 @@ } }, "nav": { - "home": "Home" + "home": "Home", + "login": "Log In", + "logout": "Log Out" }, "pagination": { "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": { "default": "Loading...", "top-level-communities": "Loading top level communities...", @@ -175,5 +226,31 @@ "group-expand": "Expand", "group-collapse-help": "Click here to collapse", "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." + } } } diff --git a/src/app/+admin/admin-registries/admin-registries-routing.module.ts b/src/app/+admin/admin-registries/admin-registries-routing.module.ts new file mode 100644 index 0000000000..8e3c322bc8 --- /dev/null +++ b/src/app/+admin/admin-registries/admin-registries-routing.module.ts @@ -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 { + +} diff --git a/src/app/+admin/admin-registries/admin-registries.module.ts b/src/app/+admin/admin-registries/admin-registries.module.ts new file mode 100644 index 0000000000..8ff42646ac --- /dev/null +++ b/src/app/+admin/admin-registries/admin-registries.module.ts @@ -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 { + +} diff --git a/src/app/+admin/admin-registries/bitstream-formats/bitstream-formats.component.html b/src/app/+admin/admin-registries/bitstream-formats/bitstream-formats.component.html new file mode 100644 index 0000000000..1ac547653f --- /dev/null +++ b/src/app/+admin/admin-registries/bitstream-formats/bitstream-formats.component.html @@ -0,0 +1,42 @@ +
+
+
+ + + +

{{'admin.registries.bitstream-formats.description' | translate}}

+ + +
+ + + + + + + + + + + + + + + +
{{'admin.registries.bitstream-formats.formats.table.name' | translate}}{{'admin.registries.bitstream-formats.formats.table.mimetype' | translate}}{{'admin.registries.bitstream-formats.formats.table.supportLevel.head' | translate}}
{{bitstreamFormat.shortDescription}}{{bitstreamFormat.mimetype}} ({{'admin.registries.bitstream-formats.formats.table.internal' | translate}}){{'admin.registries.bitstream-formats.formats.table.supportLevel.'+bitstreamFormat.supportLevel | translate}}
+
+
+ + +
+
+
diff --git a/src/app/+admin/admin-registries/bitstream-formats/bitstream-formats.component.spec.ts b/src/app/+admin/admin-registries/bitstream-formats/bitstream-formats.component.spec.ts new file mode 100644 index 0000000000..f720c336e5 --- /dev/null +++ b/src/app/+admin/admin-registries/bitstream-formats/bitstream-formats.component.spec.ts @@ -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; + 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'); + }); + +}); diff --git a/src/app/+admin/admin-registries/bitstream-formats/bitstream-formats.component.ts b/src/app/+admin/admin-registries/bitstream-formats/bitstream-formats.component.ts new file mode 100644 index 0000000000..d6c84ac858 --- /dev/null +++ b/src/app/+admin/admin-registries/bitstream-formats/bitstream-formats.component.ts @@ -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>>; + 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); + } +} diff --git a/src/app/+admin/admin-registries/metadata-registry/metadata-registry.component.html b/src/app/+admin/admin-registries/metadata-registry/metadata-registry.component.html new file mode 100644 index 0000000000..49a52cec9c --- /dev/null +++ b/src/app/+admin/admin-registries/metadata-registry/metadata-registry.component.html @@ -0,0 +1,42 @@ +
+ +
diff --git a/src/app/+admin/admin-registries/metadata-registry/metadata-registry.component.spec.ts b/src/app/+admin/admin-registries/metadata-registry/metadata-registry.component.spec.ts new file mode 100644 index 0000000000..e3b2e1f2c1 --- /dev/null +++ b/src/app/+admin/admin-registries/metadata-registry/metadata-registry.component.spec.ts @@ -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; + 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'); + }); + +}); diff --git a/src/app/+admin/admin-registries/metadata-registry/metadata-registry.component.ts b/src/app/+admin/admin-registries/metadata-registry/metadata-registry.component.ts new file mode 100644 index 0000000000..15dc6b0d80 --- /dev/null +++ b/src/app/+admin/admin-registries/metadata-registry/metadata-registry.component.ts @@ -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>>; + 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); + } + +} diff --git a/src/app/+admin/admin-registries/metadata-schema/metadata-schema.component.html b/src/app/+admin/admin-registries/metadata-schema/metadata-schema.component.html new file mode 100644 index 0000000000..e9734888ae --- /dev/null +++ b/src/app/+admin/admin-registries/metadata-schema/metadata-schema.component.html @@ -0,0 +1,41 @@ +
+ +
diff --git a/src/app/+admin/admin-registries/metadata-schema/metadata-schema.component.spec.ts b/src/app/+admin/admin-registries/metadata-schema/metadata-schema.component.spec.ts new file mode 100644 index 0000000000..7e6064ddff --- /dev/null +++ b/src/app/+admin/admin-registries/metadata-schema/metadata-schema.component.spec.ts @@ -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; + 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'); + }); +}); diff --git a/src/app/+admin/admin-registries/metadata-schema/metadata-schema.component.ts b/src/app/+admin/admin-registries/metadata-schema/metadata-schema.component.ts new file mode 100644 index 0000000000..2f0bfdeddb --- /dev/null +++ b/src/app/+admin/admin-registries/metadata-schema/metadata-schema.component.ts @@ -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>; + metadataFields: Observable>>; + 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 }; + }); + } + +} diff --git a/src/app/+admin/admin-routing.module.ts b/src/app/+admin/admin-routing.module.ts new file mode 100644 index 0000000000..e7c96bb9c4 --- /dev/null +++ b/src/app/+admin/admin-routing.module.ts @@ -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 { + +} diff --git a/src/app/+admin/admin.module.ts b/src/app/+admin/admin.module.ts new file mode 100644 index 0000000000..b979813376 --- /dev/null +++ b/src/app/+admin/admin.module.ts @@ -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 { + +} diff --git a/src/app/+login-page/login-page-routing.module.ts b/src/app/+login-page/login-page-routing.module.ts new file mode 100644 index 0000000000..4e932c50ce --- /dev/null +++ b/src/app/+login-page/login-page-routing.module.ts @@ -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 { } diff --git a/src/app/+login-page/login-page.component.html b/src/app/+login-page/login-page.component.html new file mode 100644 index 0000000000..6dcb11fbb0 --- /dev/null +++ b/src/app/+login-page/login-page.component.html @@ -0,0 +1,9 @@ +
+
+
+ +

{{"login.form.header" | translate}}

+ +
+
+
diff --git a/src/app/+login-page/login-page.component.scss b/src/app/+login-page/login-page.component.scss new file mode 100644 index 0000000000..38adf24671 --- /dev/null +++ b/src/app/+login-page/login-page.component.scss @@ -0,0 +1,6 @@ +@import '../../styles/variables.scss'; + +.login-logo { + height: $login-logo-height; + width: $login-logo-width; +} diff --git a/src/app/+login-page/login-page.component.spec.ts b/src/app/+login-page/login-page.component.spec.ts new file mode 100644 index 0000000000..609cf47794 --- /dev/null +++ b/src/app/+login-page/login-page.component.spec.ts @@ -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; + + const store: Store = 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() + }); + +}); diff --git a/src/app/+login-page/login-page.component.ts b/src/app/+login-page/login-page.component.ts new file mode 100644 index 0000000000..2752973130 --- /dev/null +++ b/src/app/+login-page/login-page.component.ts @@ -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) {} + + ngOnDestroy() { + // Clear all authentication messages when leaving login page + this.store.dispatch(new ResetAuthenticationMessagesAction()); + } +} diff --git a/src/app/+login-page/login-page.module.ts b/src/app/+login-page/login-page.module.ts new file mode 100644 index 0000000000..4d3f726c40 --- /dev/null +++ b/src/app/+login-page/login-page.module.ts @@ -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 { + +} diff --git a/src/app/+logout-page/logout-page-routing.module.ts b/src/app/+logout-page/logout-page-routing.module.ts new file mode 100644 index 0000000000..64894c1f87 --- /dev/null +++ b/src/app/+logout-page/logout-page-routing.module.ts @@ -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 { } diff --git a/src/app/+logout-page/logout-page.component.html b/src/app/+logout-page/logout-page.component.html new file mode 100644 index 0000000000..9c6185b665 --- /dev/null +++ b/src/app/+logout-page/logout-page.component.html @@ -0,0 +1,9 @@ +
+
+
+ +

{{"logout.form.header" | translate}}

+ +
+
+
diff --git a/src/app/+logout-page/logout-page.component.scss b/src/app/+logout-page/logout-page.component.scss new file mode 100644 index 0000000000..7e594c0d9b --- /dev/null +++ b/src/app/+logout-page/logout-page.component.scss @@ -0,0 +1 @@ +@import '../+login-page/login-page.component.scss'; diff --git a/src/app/+logout-page/logout-page.component.spec.ts b/src/app/+logout-page/logout-page.component.spec.ts new file mode 100644 index 0000000000..5fd4e076f2 --- /dev/null +++ b/src/app/+logout-page/logout-page.component.spec.ts @@ -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; + + 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() + }); + +}); diff --git a/src/app/+logout-page/logout-page.component.ts b/src/app/+logout-page/logout-page.component.ts new file mode 100644 index 0000000000..4fa4b9900a --- /dev/null +++ b/src/app/+logout-page/logout-page.component.ts @@ -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 { + +} diff --git a/src/app/+logout-page/logout-page.module.ts b/src/app/+logout-page/logout-page.module.ts new file mode 100644 index 0000000000..b085a5117b --- /dev/null +++ b/src/app/+logout-page/logout-page.module.ts @@ -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 { + +} diff --git a/src/app/+search-page/search-filters/search-filter/search-filter.service.ts b/src/app/+search-page/search-filters/search-filter/search-filter.service.ts index 44d9c7e709..695e0204f2 100644 --- a/src/app/+search-page/search-filters/search-filter/search-filter.service.ts +++ b/src/app/+search-page/search-filters/search-filter/search-filter.service.ts @@ -14,7 +14,7 @@ import { import { hasValue, isEmpty, isNotEmpty, } from '../../../shared/empty.util'; import { SearchFilterConfig } from '../../search-service/search-filter-config.model'; 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 { SortDirection, SortOptions } from '../../../core/cache/models/sort-options.model'; import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model'; diff --git a/src/app/+search-page/search-options.model.ts b/src/app/+search-page/search-options.model.ts index df8d8e713a..4b4987a096 100644 --- a/src/app/+search-page/search-options.model.ts +++ b/src/app/+search-page/search-options.model.ts @@ -1,6 +1,6 @@ import { isNotEmpty } from '../shared/empty.util'; import { URLCombiner } from '../core/url-combiner/url-combiner'; -import 'core-js/fn/object/entries'; +import 'core-js/library/fn/object/entries'; export enum ViewMode { List = 'list', diff --git a/src/app/+search-page/search-page.component.html b/src/app/+search-page/search-page.component.html index 1a1f379920..e8dee94139 100644 --- a/src/app/+search-page/search-page.component.html +++ b/src/app/+search-page/search-page.component.html @@ -1,8 +1,8 @@
- +
- { provide: HostWindowService, useValue: jasmine.createSpyObj('hostWindowService', { isXs: Observable.of(true), - isSm: Observable.of(false) + isSm: Observable.of(false), + isXsOrSm: Observable.of(true) }) }, { diff --git a/src/app/+search-page/search-page.component.ts b/src/app/+search-page/search-page.component.ts index 4f50723ced..63e72960d8 100644 --- a/src/app/+search-page/search-page.component.ts +++ b/src/app/+search-page/search-page.component.ts @@ -34,7 +34,7 @@ export class SearchPageComponent implements OnInit { searchOptions$: Observable; sortConfig: SortOptions; scopeListRD$: Observable>>; - isMobileView$: Observable; + isXsOrSm$: Observable; pageSize; pageSizeOptions; defaults = { @@ -52,11 +52,7 @@ export class SearchPageComponent implements OnInit { private sidebarService: SearchSidebarService, private windowService: HostWindowService, private filterService: SearchFilterService) { - this.isMobileView$ = Observable.combineLatest( - this.windowService.isXs(), - this.windowService.isSm(), - ((isXs, isSm) => isXs || isSm) - ); + this.isXsOrSm$ = this.windowService.isXsOrSm(); this.scopeListRD$ = communityService.findAll(); } diff --git a/src/app/+search-page/search-service/search.service.spec.ts b/src/app/+search-page/search-service/search.service.spec.ts index 4b558f8726..5cc71d9bbd 100644 --- a/src/app/+search-page/search-service/search.service.spec.ts +++ b/src/app/+search-page/search-service/search.service.spec.ts @@ -7,7 +7,7 @@ import { Component } from '@angular/core'; import { SearchService } from './search.service'; import { ItemDataService } from './../../core/data/item-data.service'; 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 { RemoteDataBuildService } from '../../core/cache/builders/remote-data-build.service'; import { ActivatedRoute, Router, UrlTree } from '@angular/router'; diff --git a/src/app/+search-page/search-sidebar/search-sidebar.service.spec.ts b/src/app/+search-page/search-sidebar/search-sidebar.service.spec.ts index 8b817d5523..b6439be4df 100644 --- a/src/app/+search-page/search-sidebar/search-sidebar.service.spec.ts +++ b/src/app/+search-page/search-sidebar/search-sidebar.service.spec.ts @@ -3,6 +3,7 @@ import { SearchSidebarService } from './search-sidebar.service'; import { AppState } from '../../app.reducer'; import { async, inject, TestBed } from '@angular/core/testing'; import { Observable } from 'rxjs/Observable'; +import 'rxjs/add/observable/of'; import { SearchSidebarCollapseAction, SearchSidebarExpandAction } from './search-sidebar.actions'; import { HostWindowService } from '../../shared/host-window.service'; @@ -17,7 +18,8 @@ describe('SearchSidebarService', () => { const windowService = jasmine.createSpyObj('hostWindowService', { isXs: Observable.of(true), - isSm: Observable.of(false) + isSm: Observable.of(false), + isXsOrSm: Observable.of(true) }); beforeEach(async(() => { TestBed.configureTestingModule({ diff --git a/src/app/+search-page/search-sidebar/search-sidebar.service.ts b/src/app/+search-page/search-sidebar/search-sidebar.service.ts index e2ad5e0960..3a17dc87ab 100644 --- a/src/app/+search-page/search-sidebar/search-sidebar.service.ts +++ b/src/app/+search-page/search-sidebar/search-sidebar.service.ts @@ -11,22 +11,17 @@ const sidebarCollapsedSelector = createSelector(sidebarStateSelector, (sidebar: @Injectable() export class SearchSidebarService { - private isMobileView: Observable; + private isXsOrSm$: Observable; private isCollapsdeInStored: Observable; constructor(private store: Store, private windowService: HostWindowService) { - this.isMobileView = - Observable.combineLatest( - this.windowService.isXs(), - this.windowService.isSm(), - ((isXs, isSm) => isXs || isSm) - ); + this.isXsOrSm$ = this.windowService.isXsOrSm(); this.isCollapsdeInStored = this.store.select(sidebarCollapsedSelector); } get isCollapsed(): Observable { return Observable.combineLatest( - this.isMobileView, + this.isXsOrSm$, this.isCollapsdeInStored, (mobile, store) => mobile ? store : true); } diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts index dc442cd485..4bc8c43152 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -12,6 +12,9 @@ import { PageNotFoundComponent } from './pagenotfound/pagenotfound.component'; { path: 'collections', loadChildren: './+collection-page/collection-page.module#CollectionPageModule' }, { path: 'items', loadChildren: './+item-page/item-page.module#ItemPageModule' }, { 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 }, ]) ], diff --git a/src/app/app.component.spec.ts b/src/app/app.component.spec.ts index 391ff422c8..cbab798f1e 100644 --- a/src/app/app.component.spec.ts +++ b/src/app/app.component.spec.ts @@ -26,12 +26,14 @@ import { HostWindowResizeAction } from './shared/host-window.actions'; import { MetadataService } from './core/metadata/metadata.service'; 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 { MockMetadataService } from './shared/mocks/mock-metadata-service'; import { Angulartics2GoogleAnalytics } from 'angulartics2/ga'; 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 fixture: ComponentFixture; @@ -59,6 +61,7 @@ describe('App component', () => { { provide: NativeWindowService, useValue: new NativeWindowRef() }, { provide: MetadataService, useValue: new MockMetadataService() }, { provide: Angulartics2GoogleAnalytics, useValue: new AngularticsMock() }, + { provide: AuthService, useValue: new AuthServiceMock() }, AppComponent ], schemas: [CUSTOM_ELEMENTS_SCHEMA] diff --git a/src/app/app.component.ts b/src/app/app.component.ts index a367aaed40..1c1a47cf12 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -9,7 +9,9 @@ import { GLOBAL_CONFIG, GlobalConfig } from '../config'; import { MetadataService } from './core/metadata/metadata.service'; import { HostWindowResizeAction } from './shared/host-window.actions'; 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'; @Component({ @@ -27,7 +29,8 @@ export class AppComponent implements OnInit { private translate: TranslateService, private store: Store, 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 translate.setDefaultLang('en'); @@ -46,6 +49,13 @@ export class AppComponent implements OnInit { const color: string = this.config.production ? 'red' : 'green'; console.info(`Environment: %c${env}`, `color: ${color}; font-weight: bold;`); this.dispatchWindowSize(this._window.nativeWindow.innerWidth, this._window.nativeWindow.innerHeight); + + // Whether is not authenticathed try to retrieve a possible stored auth token + this.store.select(isAuthenticated) + .take(1) + .filter((authenticated) => !authenticated) + .subscribe((authenticated) => this.authService.checkAuthenticationToken()); + } @HostListener('window:resize', ['$event']) diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 786ee4ebbf..528c84fd3b 100755 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -30,6 +30,7 @@ import { PageNotFoundComponent } from './pagenotfound/pagenotfound.component'; import { DSpaceRouterStateSerializer } from './shared/ngrx/dspace-router-state-serializer'; import { NotificationsBoardComponent } from './shared/notifications/notifications-board/notifications-board.component'; import { NotificationComponent } from './shared/notifications/notification/notification.component'; +import { SharedModule } from './shared/shared.module'; export function getConfig() { return ENV_CONFIG; @@ -53,6 +54,7 @@ if (!ENV_CONFIG.production) { @NgModule({ imports: [ CommonModule, + SharedModule, HttpClientModule, AppRoutingModule, CoreModule.forRoot(), diff --git a/src/app/app.reducer.ts b/src/app/app.reducer.ts index 77c8efcab2..8dc82dfb6f 100644 --- a/src/app/app.reducer.ts +++ b/src/app/app.reducer.ts @@ -36,3 +36,5 @@ export const appReducers: ActionReducerMap = { searchFilter: filterReducer, truncatable: truncatableReducer }; + +export const routerStateSelector = (state: AppState) => state.router; diff --git a/src/app/core/auth/auth-object-factory.ts b/src/app/core/auth/auth-object-factory.ts new file mode 100644 index 0000000000..c3e70eaaac --- /dev/null +++ b/src/app/core/auth/auth-object-factory.ts @@ -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 { + switch (type) { + case AuthType.Eperson: { + return NormalizedEpersonModel + } + + case AuthType.Status: { + return NormalizedAuthStatus + } + + default: { + return undefined; + } + } + } +} diff --git a/src/app/core/auth/auth-request.service.ts b/src/app/core/auth/auth-request.service.ts new file mode 100644 index 0000000000..a57ad48865 --- /dev/null +++ b/src/app/core/auth/auth-request.service.ts @@ -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 { + 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 { + 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 { + 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(); + } +} diff --git a/src/app/core/auth/auth-response-parsing.service.spec.ts b/src/app/core/auth/auth-response-parsing.service.spec.ts new file mode 100644 index 0000000000..f7d899a9bc --- /dev/null +++ b/src/app/core/auth/auth-response-parsing.service.spec.ts @@ -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; + 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); + }); + + }); +}); diff --git a/src/app/core/auth/auth-response-parsing.service.ts b/src/app/core/auth/auth-response-parsing.service.ts new file mode 100644 index 0000000000..80c1b2eeca --- /dev/null +++ b/src/app/core/auth/auth-response-parsing.service.ts @@ -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(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); + } + } + +} diff --git a/src/app/core/auth/auth-type.ts b/src/app/core/auth/auth-type.ts new file mode 100644 index 0000000000..b8879ae445 --- /dev/null +++ b/src/app/core/auth/auth-type.ts @@ -0,0 +1,4 @@ +export enum AuthType { + Eperson = 'eperson', + Status = 'status' +} diff --git a/src/app/core/auth/auth.actions.ts b/src/app/core/auth/auth.actions.ts new file mode 100644 index 0000000000..207e8fae70 --- /dev/null +++ b/src/app/core/auth/auth.actions.ts @@ -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; diff --git a/src/app/core/auth/auth.effects.spec.ts b/src/app/core/auth/auth.effects.spec.ts new file mode 100644 index 0000000000..3b569e523f --- /dev/null +++ b/src/app/core/auth/auth.effects.spec.ts @@ -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; + + const authServiceStub = new AuthServiceStub(); + const store: Store = 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); + }); + }) + }); +}); diff --git a/src/app/core/auth/auth.effects.ts b/src/app/core/auth/auth.effects.ts new file mode 100644 index 0000000000..e2d6c80b5e --- /dev/null +++ b/src/app/core/auth/auth.effects.ts @@ -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 = 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 = 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 = 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 = this.actions$ + .ofType(AuthActionTypes.AUTHENTICATED_ERROR) + .do((action: LogOutSuccessAction) => this.authService.removeToken()); + + @Effect() + public checkToken$: Observable = 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 = 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 = 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 = 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 = 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 = 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 = 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 = this.actions$ + .ofType(AuthActionTypes.REDIRECT_AUTHENTICATION_REQUIRED) + .do(() => this.authService.removeToken()) + .do(() => this.authService.redirectToLogin()); + + @Effect({dispatch: false}) + public redirectToLoginTokenExpired$: Observable = 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) { + } +} diff --git a/src/app/core/auth/auth.interceptor.spec.ts b/src/app/core/auth/auth.interceptor.spec.ts new file mode 100644 index 0000000000..528c2cfab3 --- /dev/null +++ b/src/app/core/auth/auth.interceptor.spec.ts @@ -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 = 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 we’re 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 we’re 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'); + }); + }) + +}); diff --git a/src/app/core/auth/auth.interceptor.ts b/src/app/core/auth/auth.interceptor.ts new file mode 100644 index 0000000000..651e2fd096 --- /dev/null +++ b/src/app/core/auth/auth.interceptor.ts @@ -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) { } + + 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 | 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 | HttpResponseBase): boolean { + return http.url && http.url.endsWith('/authn/login'); + } + + private isLogoutResponse(http: HttpRequest | 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, next: HttpHandler): Observable> { + + 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; + 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; + + } +} diff --git a/src/app/core/auth/auth.reducer.spec.ts b/src/app/core/auth/auth.reducer.spec.ts new file mode 100644 index 0000000000..f148f3ac8d --- /dev/null +++ b/src/app/core/auth/auth.reducer.spec.ts @@ -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); + }); +}); diff --git a/src/app/core/auth/auth.reducer.ts b/src/app/core/auth/auth.reducer.ts new file mode 100644 index 0000000000..0c5e36ce91 --- /dev/null +++ b/src/app/core/auth/auth.reducer.ts @@ -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; + } +} diff --git a/src/app/core/auth/auth.service.spec.ts b/src/app/core/auth/auth.service.spec.ts new file mode 100644 index 0000000000..b54f65078e --- /dev/null +++ b/src/app/core/auth/auth.service.spec.ts @@ -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 = 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, 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, 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(); + }); + + }); +}); diff --git a/src/app/core/auth/auth.service.ts b/src/app/core/auth/auth.service.ts new file mode 100644 index 0000000000..2848b54b50 --- /dev/null +++ b/src/app/core/auth/auth.service.ts @@ -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) { + 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} The authenticated user observable. + */ + public authenticate(user: string, password: string): Observable { + // 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} + */ + public isAuthenticated(): Observable { + return this.store.select(isAuthenticated); + } + + /** + * Returns the authenticated user + * @returns {User} + */ + public authenticatedUser(token: AuthTokenInfo): Observable { + // 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 { + 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 { + 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 { + // 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} + */ + public logout(): Observable { + // 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 { + 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 { + 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); + } +} diff --git a/src/app/core/auth/authenticated.guard.ts b/src/app/core/auth/authenticated.guard.ts new file mode 100644 index 0000000000..42c39b403c --- /dev/null +++ b/src/app/core/auth/authenticated.guard.ts @@ -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) {} + + /** + * True when user is authenticated + * @method canActivate + */ + canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { + const url = state.url; + return this.handleAuth(url); + } + + /** + * True when user is authenticated + * @method canActivateChild + */ + canActivateChild(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { + return this.canActivate(route, state); + } + + /** + * True when user is authenticated + * @method canLoad + */ + canLoad(route: Route): Observable { + const url = `/${route.path}`; + + return this.handleAuth(url); + } + + private handleAuth(url: string): Observable { + // 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; + } +} diff --git a/src/app/core/auth/models/auth-error.model.ts b/src/app/core/auth/models/auth-error.model.ts new file mode 100644 index 0000000000..d68d04748e --- /dev/null +++ b/src/app/core/auth/models/auth-error.model.ts @@ -0,0 +1,7 @@ +export interface AuthError { + error: string, + message: string, + path: string, + status: number + timestamp: number +} diff --git a/src/app/core/auth/models/auth-status.model.ts b/src/app/core/auth/models/auth-status.model.ts new file mode 100644 index 0000000000..22c9d14718 --- /dev/null +++ b/src/app/core/auth/models/auth-status.model.ts @@ -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; +} diff --git a/src/app/core/auth/models/auth-token-info.model.ts b/src/app/core/auth/models/auth-token-info.model.ts new file mode 100644 index 0000000000..dc7c71b660 --- /dev/null +++ b/src/app/core/auth/models/auth-token-info.model.ts @@ -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; + } + } +} diff --git a/src/app/core/auth/models/normalized-auth-status.model.ts b/src/app/core/auth/models/normalized-auth-status.model.ts new file mode 100644 index 0000000000..19952f7c70 --- /dev/null +++ b/src/app/core/auth/models/normalized-auth-status.model.ts @@ -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[]; + +} diff --git a/src/app/core/auth/selectors.ts b/src/app/core/auth/selectors.ts new file mode 100644 index 0000000000..fa637981ae --- /dev/null +++ b/src/app/core/auth/selectors.ts @@ -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); diff --git a/src/app/core/auth/server-auth.service.ts b/src/app/core/auth/server-auth.service.ts new file mode 100644 index 0000000000..96ee2e355a --- /dev/null +++ b/src/app/core/auth/server-auth.service.ts @@ -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 { + // 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(['/']); + } + }) + + } + +} diff --git a/src/app/core/cache/response-cache.models.ts b/src/app/core/cache/response-cache.models.ts index 562ed27f32..ea8ea5bab4 100644 --- a/src/app/core/cache/response-cache.models.ts +++ b/src/app/core/cache/response-cache.models.ts @@ -7,12 +7,20 @@ import { ConfigObject } from '../shared/config/config.model'; import { FacetValue } from '../../+search-page/search-service/facet-value.model'; import { SearchFilterConfig } from '../../+search-page/search-service/search-filter-config.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 */ export class RestResponse { + public toCache = true; constructor( 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 { constructor( 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 { constructor( public dataDefinition: IntegrationModel[], diff --git a/src/app/core/cache/response-cache.service.ts b/src/app/core/cache/response-cache.service.ts index 77a2402043..a0e3740094 100644 --- a/src/app/core/cache/response-cache.service.ts +++ b/src/app/core/cache/response-cache.service.ts @@ -65,6 +65,11 @@ export class ResponseCacheService { return result; } + remove(key: string): void { + if (this.has(key)) { + this.store.dispatch(new ResponseCacheRemoveAction(key)); + } + } /** * Check whether a ResponseCacheEntry should still be cached * diff --git a/src/app/core/core.effects.ts b/src/app/core/core.effects.ts index ebb87bf1ee..bc534a36b0 100644 --- a/src/app/core/core.effects.ts +++ b/src/app/core/core.effects.ts @@ -3,10 +3,12 @@ import { ObjectCacheEffects } from './cache/object-cache.effects'; import { ResponseCacheEffects } from './cache/response-cache.effects'; import { UUIDIndexEffects } from './index/index.effects'; import { RequestEffects } from './data/request.effects'; +import { AuthEffects } from './auth/auth.effects'; export const coreEffects = [ ResponseCacheEffects, RequestEffects, ObjectCacheEffects, - UUIDIndexEffects + UUIDIndexEffects, + AuthEffects ]; diff --git a/src/app/core/core.module.ts b/src/app/core/core.module.ts index 42992dd0db..8536169688 100644 --- a/src/app/core/core.module.ts +++ b/src/app/core/core.module.ts @@ -15,7 +15,7 @@ import { coreReducers } from './core.reducers'; 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 { CollectionDataService } from './data/collection-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 { ResponseCacheService } from './cache/response-cache.service'; import { EndpointMapResponseParsingService } from './data/endpoint-map-response-parsing.service'; -import { ServerResponseService } from '../shared/server-response.service'; -import { NativeWindowFactory, NativeWindowService } from '../shared/window.service'; +import { ServerResponseService } from '../shared/services/server-response.service'; +import { NativeWindowFactory, NativeWindowService } from '../shared/services/window.service'; import { BrowseService } from './browse/browse.service'; import { BrowseResponseParsingService } from './data/browse-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 { SubmissionFormsConfigService } from './config/submission-forms-config.service'; import { SubmissionSectionsConfigService } from './config/submission-sections-config.service'; import { AuthorityService } from './integration/authority.service'; import { IntegrationResponseParsingService } from './integration/integration-response-parsing.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 { FacetValueResponseParsingService } from './data/facet-value-response-parsing.service'; import { FacetValueMapResponseParsingService } from './data/facet-value-map-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 { UploaderService } from '../shared/uploader/uploader.service'; @@ -69,6 +79,9 @@ const EXPORTS = [ const PROVIDERS = [ ApiService, + AuthenticatedGuard, + AuthRequestService, + AuthResponseParsingService, CommunityDataService, CollectionDataService, DSOResponseParsingService, @@ -84,6 +97,7 @@ const PROVIDERS = [ MetadataService, ObjectCacheService, PaginationComponentOptions, + RegistryService, RemoteDataBuildService, RequestService, ResponseCacheService, @@ -91,6 +105,10 @@ const PROVIDERS = [ FacetValueResponseParsingService, FacetValueMapResponseParsingService, FacetConfigResponseParsingService, + RegistryMetadataschemasResponseParsingService, + RegistryMetadatafieldsResponseParsingService, + RegistryBitstreamformatsResponseParsingService, + MetadataschemaParsingService, DebugResponseParsingService, SearchResponseParsingService, ServerResponseService, @@ -106,6 +124,12 @@ const PROVIDERS = [ IntegrationResponseParsingService, UploaderService, UUIDService, + // register AuthInterceptor as HttpInterceptor + { + provide: HTTP_INTERCEPTORS, + useClass: AuthInterceptor, + multi: true + }, NotificationsService, { provide: NativeWindowService, useFactory: NativeWindowFactory } ]; diff --git a/src/app/core/core.reducers.ts b/src/app/core/core.reducers.ts index d2898eb3c3..c764a2acff 100644 --- a/src/app/core/core.reducers.ts +++ b/src/app/core/core.reducers.ts @@ -4,19 +4,22 @@ import { responseCacheReducer, ResponseCacheState } from './cache/response-cache import { objectCacheReducer, ObjectCacheState } from './cache/object-cache.reducer'; import { indexReducer, IndexState } from './index/index.reducer'; import { requestReducer, RequestState } from './data/request.reducer'; +import { authReducer, AuthState } from './auth/auth.reducer'; export interface CoreState { 'data/object': ObjectCacheState, 'data/response': ResponseCacheState, 'data/request': RequestState, - 'index': IndexState + 'index': IndexState, + 'auth': AuthState, } export const coreReducers: ActionReducerMap = { 'data/object': objectCacheReducer, 'data/response': responseCacheReducer, 'data/request': requestReducer, - 'index': indexReducer + 'index': indexReducer, + 'auth': authReducer }; export const coreSelector = createFeatureSelector('core'); diff --git a/src/app/core/data/metadataschema-parsing.service.ts b/src/app/core/data/metadataschema-parsing.service.ts new file mode 100644 index 0000000000..cdd87c19d4 --- /dev/null +++ b/src/app/core/data/metadataschema-parsing.service.ts @@ -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); + } + +} diff --git a/src/app/core/data/registry-bitstreamformats-response-parsing.service.ts b/src/app/core/data/registry-bitstreamformats-response-parsing.service.ts new file mode 100644 index 0000000000..d981a12719 --- /dev/null +++ b/src/app/core/data/registry-bitstreamformats-response-parsing.service.ts @@ -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)); + } + +} diff --git a/src/app/core/data/registry-metadatafields-response-parsing.service.ts b/src/app/core/data/registry-metadatafields-response-parsing.service.ts new file mode 100644 index 0000000000..2620916070 --- /dev/null +++ b/src/app/core/data/registry-metadatafields-response-parsing.service.ts @@ -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)); + } + +} diff --git a/src/app/core/data/registry-metadataschemas-response-parsing.service.ts b/src/app/core/data/registry-metadataschemas-response-parsing.service.ts new file mode 100644 index 0000000000..2bb1302450 --- /dev/null +++ b/src/app/core/data/registry-metadataschemas-response-parsing.service.ts @@ -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)); + } + +} diff --git a/src/app/core/data/request.effects.ts b/src/app/core/data/request.effects.ts index 379540c779..2f9d168f74 100644 --- a/src/app/core/data/request.effects.ts +++ b/src/app/core/data/request.effects.ts @@ -33,9 +33,9 @@ export class RequestEffects { let body; if (isNotEmpty(request.body)) { 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) => this.injector.get(request.getResponseParser()).parse(request, data)) .do((response: RestResponse) => this.responseCache.add(request.href, response, this.EnvConfig.cache.msToLive)) diff --git a/src/app/core/data/request.models.ts b/src/app/core/data/request.models.ts index aa9b66a406..7015b0b0f1 100644 --- a/src/app/core/data/request.models.ts +++ b/src/app/core/data/request.models.ts @@ -8,6 +8,9 @@ import { ResponseParsingService } from './parsing.service'; import { EndpointMapResponseParsingService } from './endpoint-map-response-parsing.service'; import { BrowseResponseParsingService } from './browse-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'; /* tslint:disable:max-classes-per-file */ @@ -36,7 +39,8 @@ export abstract class RestRequest { public uuid: string, public href: string, public method: RestRequestMethod = RestRequestMethod.Get, - public body?: any + public body?: any, + public options?: HttpOptions ) { } @@ -49,7 +53,8 @@ export class GetRequest extends RestRequest { constructor( public uuid: string, public href: string, - public body?: any + public body?: any, + public options?: HttpOptions ) { super(uuid, href, RestRequestMethod.Get, body) } @@ -59,7 +64,8 @@ export class PostRequest extends RestRequest { constructor( public uuid: string, public href: string, - public body?: any + public body?: any, + public options?: HttpOptions ) { super(uuid, href, RestRequestMethod.Post, body) } @@ -69,7 +75,8 @@ export class PutRequest extends RestRequest { constructor( public uuid: string, public href: string, - public body?: any + public body?: any, + public options?: HttpOptions ) { super(uuid, href, RestRequestMethod.Put, body) } @@ -79,7 +86,8 @@ export class DeleteRequest extends RestRequest { constructor( public uuid: string, public href: string, - public body?: any + public body?: any, + public options?: HttpOptions ) { super(uuid, href, RestRequestMethod.Delete, body) } @@ -89,7 +97,8 @@ export class OptionsRequest extends RestRequest { constructor( public uuid: string, public href: string, - public body?: any + public body?: any, + public options?: HttpOptions ) { super(uuid, href, RestRequestMethod.Options, body) } @@ -99,7 +108,8 @@ export class HeadRequest extends RestRequest { constructor( public uuid: string, public href: string, - public body?: any + public body?: any, + public options?: HttpOptions ) { super(uuid, href, RestRequestMethod.Head, body) } @@ -109,7 +119,8 @@ export class PatchRequest extends RestRequest { constructor( public uuid: string, public href: string, - public body?: any + public body?: any, + public options?: HttpOptions ) { super(uuid, href, RestRequestMethod.Patch, body) } @@ -136,7 +147,7 @@ export class FindAllRequest extends GetRequest { constructor( uuid: string, href: string, - public options?: FindAllOptions, + public body?: FindAllOptions, ) { 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 { + return AuthResponseParsingService; + } +} + +export class AuthGetRequest extends GetRequest { + constructor(uuid: string, href: string, public options?: HttpOptions) { + super(uuid, href, null, options); + } + + getResponseParser(): GenericConstructor { + return AuthResponseParsingService; + } +} + export class IntegrationRequest extends GetRequest { constructor(uuid: string, href: string) { super(uuid, href); diff --git a/src/app/core/data/request.service.spec.ts b/src/app/core/data/request.service.spec.ts index 17d4a89d05..aa9954f680 100644 --- a/src/app/core/data/request.service.spec.ts +++ b/src/app/core/data/request.service.spec.ts @@ -1,6 +1,7 @@ import { Store } from '@ngrx/store'; import { cold, hot } from 'jasmine-marbles'; import { Observable } from 'rxjs/Observable'; +import 'rxjs/add/observable/of'; import { getMockObjectCacheService } from '../../shared/mocks/mock-object-cache.service'; import { getMockResponseCacheService } from '../../shared/mocks/mock-response-cache.service'; import { getMockStore } from '../../shared/mocks/mock-store'; @@ -229,6 +230,11 @@ describe('RequestService', () => { 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', () => { beforeEach(() => { spyOn(serviceAsAny, 'isCachedOrPending').and.returnValue(false); @@ -271,6 +277,28 @@ describe('RequestService', () => { service.configure(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); 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', () => { diff --git a/src/app/core/data/request.service.ts b/src/app/core/data/request.service.ts index f589221e63..12933f83fc 100644 --- a/src/app/core/data/request.service.ts +++ b/src/app/core/data/request.service.ts @@ -17,6 +17,7 @@ import { RequestConfigureAction, RequestExecuteAction } from './request.actions' import { GetRequest, RestRequest, RestRequestMethod } from './request.models'; import { RequestEntry, RequestState } from './request.reducer'; +import { ResponseCacheRemoveAction } from '../cache/response-cache.actions'; @Injectable() export class RequestService { @@ -66,9 +67,14 @@ export class RequestService { .flatMap((uuid: string) => this.getByUUID(uuid)); } - configure(request: RestRequest): void { - if (request.method !== RestRequestMethod.Get || !this.isCachedOrPending(request)) { + // TODO to review "overrideRequest" param when https://github.com/DSpace/dspace-angular/issues/217 will be fixed + configure(request: RestRequest, forceBypassCache: boolean = false): void { + const isGetRequest = request.method === RestRequestMethod.Get; + if (!isGetRequest || !this.isCachedOrPending(request) || forceBypassCache) { this.dispatchRequest(request); + if (isGetRequest && !forceBypassCache) { + this.trackRequestsOnTheirWayToTheStore(request); + } } } @@ -104,9 +110,6 @@ export class RequestService { private dispatchRequest(request: RestRequest) { this.store.dispatch(new RequestConfigureAction(request)); this.store.dispatch(new RequestExecuteAction(request.uuid)); - if (request.method === RestRequestMethod.Get) { - this.trackRequestsOnTheirWayToTheStore(request); - } } /** diff --git a/src/app/core/dspace-rest-v2/dspace-rest-v2-response.model.ts b/src/app/core/dspace-rest-v2/dspace-rest-v2-response.model.ts index d225eadcc4..17fb389707 100644 --- a/src/app/core/dspace-rest-v2/dspace-rest-v2-response.model.ts +++ b/src/app/core/dspace-rest-v2/dspace-rest-v2-response.model.ts @@ -1,3 +1,5 @@ +import { HttpHeaders } from '@angular/common/http'; + export interface DSpaceRESTV2Response { payload: { [name: string]: any; @@ -5,5 +7,6 @@ export interface DSpaceRESTV2Response { _links?: any; page?: any; }, + headers?: HttpHeaders, statusCode: string } diff --git a/src/app/core/dspace-rest-v2/dspace-rest-v2.service.ts b/src/app/core/dspace-rest-v2/dspace-rest-v2.service.ts index b2d3197723..78c93b8c08 100644 --- a/src/app/core/dspace-rest-v2/dspace-rest-v2.service.ts +++ b/src/app/core/dspace-rest-v2/dspace-rest-v2.service.ts @@ -1,10 +1,21 @@ import { Injectable } from '@angular/core'; 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 { RestRequestMethod } from '../data/request.models'; 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 @@ -45,9 +56,18 @@ export class DSpaceRESTv2Service { * @return {Observable} * An Observable containing the response from the server */ - request(method: RestRequestMethod, url: string, body?: any): Observable { - return this.http.request(method, url, { body, observe: 'response' }) - .map((res) => ({ payload: res.body, statusCode: res.statusText })) + request(method: RestRequestMethod, url: string, body?: any, options?: HttpOptions): Observable { + const requestOptions: HttpOptions = {}; + 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) => { console.log('Error: ', err); return Observable.throw(err); diff --git a/src/app/core/eperson/models/NormalizedEperson.model.ts b/src/app/core/eperson/models/NormalizedEperson.model.ts new file mode 100644 index 0000000000..0c0b2490d6 --- /dev/null +++ b/src/app/core/eperson/models/NormalizedEperson.model.ts @@ -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; +} diff --git a/src/app/core/eperson/models/NormalizedGroup.model.ts b/src/app/core/eperson/models/NormalizedGroup.model.ts new file mode 100644 index 0000000000..24f7da8eab --- /dev/null +++ b/src/app/core/eperson/models/NormalizedGroup.model.ts @@ -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; +} diff --git a/src/app/core/eperson/models/eperson.model.ts b/src/app/core/eperson/models/eperson.model.ts new file mode 100644 index 0000000000..373fb42792 --- /dev/null +++ b/src/app/core/eperson/models/eperson.model.ts @@ -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; + +} diff --git a/src/app/core/eperson/models/group.model.ts b/src/app/core/eperson/models/group.model.ts new file mode 100644 index 0000000000..cd41ce9e25 --- /dev/null +++ b/src/app/core/eperson/models/group.model.ts @@ -0,0 +1,8 @@ +import { DSpaceObject } from '../../shared/dspace-object.model'; + +export class Group extends DSpaceObject { + + public handle: string; + + public permanent: boolean; +} diff --git a/src/app/core/metadata/metadatafield.model.ts b/src/app/core/metadata/metadatafield.model.ts new file mode 100644 index 0000000000..77cecb927e --- /dev/null +++ b/src/app/core/metadata/metadatafield.model.ts @@ -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; +} diff --git a/src/app/core/metadata/metadataschema.model.ts b/src/app/core/metadata/metadataschema.model.ts new file mode 100644 index 0000000000..13fb8e8b4e --- /dev/null +++ b/src/app/core/metadata/metadataschema.model.ts @@ -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; +} diff --git a/src/app/core/registry/mock-bitstream-format.model.ts b/src/app/core/registry/mock-bitstream-format.model.ts new file mode 100644 index 0000000000..f5811e367c --- /dev/null +++ b/src/app/core/registry/mock-bitstream-format.model.ts @@ -0,0 +1,8 @@ +export class BitstreamFormat { + shortDescription: string; + description: string; + mimetype: string; + supportLevel: number; + internal: boolean; + extensions: string; +} diff --git a/src/app/core/registry/registry-bitstreamformats-response.model.ts b/src/app/core/registry/registry-bitstreamformats-response.model.ts new file mode 100644 index 0000000000..81de379e9e --- /dev/null +++ b/src/app/core/registry/registry-bitstreamformats-response.model.ts @@ -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; +} diff --git a/src/app/core/registry/registry-metadatafields-response.model.ts b/src/app/core/registry/registry-metadatafields-response.model.ts new file mode 100644 index 0000000000..19ec537dfb --- /dev/null +++ b/src/app/core/registry/registry-metadatafields-response.model.ts @@ -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; +} diff --git a/src/app/core/registry/registry-metadataschemas-response.model.ts b/src/app/core/registry/registry-metadataschemas-response.model.ts new file mode 100644 index 0000000000..5f4799abd7 --- /dev/null +++ b/src/app/core/registry/registry-metadataschemas-response.model.ts @@ -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; +} diff --git a/src/app/core/registry/registry.service.spec.ts b/src/app/core/registry/registry.service.spec.ts new file mode 100644 index 0000000000..ef1533278d --- /dev/null +++ b/src/app/core/registry/registry.service.spec.ts @@ -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, responseCacheObs: Observable, payloadObs: Observable) => { + return Observable.combineLatest(requestEntryObs, + responseCacheObs, payloadObs, (req, res, pay) => { + return { req, res, pay }; + }); + }, + aggregate: (input: Array>>): Observable> => { + 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); + }); + }); +}); diff --git a/src/app/core/registry/registry.service.ts b/src/app/core/registry/registry.service.ts new file mode 100644 index 0000000000..4359284158 --- /dev/null +++ b/src/app/core/registry/registry.service.ts @@ -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>> { + 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 = responseCacheObs.pipe( + map((entry: ResponseCacheEntry) => entry.response), + map((response: RegistryMetadataschemasSuccessResponse) => response.metadataschemasResponse) + ); + + const metadataschemasObs: Observable = rmrObs.pipe( + map((rmr: RegistryMetadataschemasResponse) => rmr.metadataschemas) + ); + + const pageInfoObs: Observable = 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> { + // 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 = responseCacheObs.pipe( + map((entry: ResponseCacheEntry) => entry.response), + map((response: RegistryMetadataschemasSuccessResponse) => response.metadataschemasResponse) + ); + + const metadataschemaObs: Observable = 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>> { + 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 = responseCacheObs.pipe( + map((entry: ResponseCacheEntry) => entry.response), + map((response: RegistryMetadatafieldsSuccessResponse) => response.metadatafieldsResponse) + ); + + const metadatafieldsObs: Observable = rmrObs.pipe( + map((rmr: RegistryMetadatafieldsResponse) => rmr.metadatafields), + map((metadataFields: MetadataField[]) => metadataFields.filter((field) => field.schema.id === schema.id)) + ); + + const pageInfoObs: Observable = 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>> { + 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 = responseCacheObs.pipe( + map((entry: ResponseCacheEntry) => entry.response), + map((response: RegistryBitstreamformatsSuccessResponse) => response.bitstreamformatsResponse) + ); + + const bitstreamformatsObs: Observable = rbrObs.pipe( + map((rbr: RegistryBitstreamformatsResponse) => rbr.bitstreamformats) + ); + + const pageInfoObs: Observable = 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 { + 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 { + return RegistryMetadataschemasResponseParsingService; + } + }); + }), + tap((request: RestRequest) => this.requestService.configure(request)), + ); + } + + private getMetadataFieldsRequestObs(pagination: PaginationComponentOptions): Observable { + 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 { + return RegistryMetadatafieldsResponseParsingService; + } + }); + }), + tap((request: RestRequest) => this.requestService.configure(request)), + ); + } + + private getBitstreamFormatsRequestObs(pagination: PaginationComponentOptions): Observable { + 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 { + return RegistryBitstreamformatsResponseParsingService; + } + }); + }), + tap((request: RestRequest) => this.requestService.configure(request)), + ); + } + +} diff --git a/src/app/core/shared/resource-type.ts b/src/app/core/shared/resource-type.ts index 1b73cb0bba..b774188f63 100644 --- a/src/app/core/shared/resource-type.ts +++ b/src/app/core/shared/resource-type.ts @@ -6,4 +6,6 @@ export enum ResourceType { Item = 'item', Collection = 'collection', Community = 'community', + Eperson = 'eperson', + Group = 'group', } diff --git a/src/app/header/header.component.html b/src/app/header/header.component.html index 953d1ba922..f47696609c 100644 --- a/src/app/header/header.component.html +++ b/src/app/header/header.component.html @@ -12,6 +12,7 @@ {{ 'nav.home' | translate }}(current) +
diff --git a/src/app/header/header.component.spec.ts b/src/app/header/header.component.spec.ts index 7c1a9601ac..87fa2995d6 100644 --- a/src/app/header/header.component.spec.ts +++ b/src/app/header/header.component.spec.ts @@ -9,6 +9,16 @@ import { Observable } from 'rxjs/Observable'; import { HeaderComponent } from './header.component'; import { HeaderState } from './header.reducer'; 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 fixture: ComponentFixture; @@ -19,8 +29,17 @@ describe('HeaderComponent', () => { // async beforeEach beforeEach(async(() => { TestBed.configureTestingModule({ - imports: [StoreModule.forRoot({}), TranslateModule.forRoot(), NgbCollapseModule.forRoot()], - declarations: [HeaderComponent] + imports: [ + 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 })); diff --git a/src/app/header/header.component.ts b/src/app/header/header.component.ts index 624ae209dd..93cb329f4f 100644 --- a/src/app/header/header.component.ts +++ b/src/app/header/header.component.ts @@ -1,10 +1,12 @@ import { Component, OnInit } from '@angular/core'; import { createSelector, Store } from '@ngrx/store'; import { Observable } from 'rxjs/Observable'; +import { RouterReducerState } from '@ngrx/router-store'; import { HeaderState } from './header.reducer'; import { HeaderToggleAction } from './header.actions'; import { AppState } from '../app.reducer'; +import { HostWindowService } from '../shared/host-window.service'; const headerStateSelector = (state: AppState) => state.header; const navCollapsedSelector = createSelector(headerStateSelector, (header: HeaderState) => header.navCollapsed); @@ -12,17 +14,25 @@ const navCollapsedSelector = createSelector(headerStateSelector, (header: Header @Component({ selector: 'ds-header', styleUrls: ['header.component.scss'], - templateUrl: 'header.component.html' + templateUrl: 'header.component.html', }) export class HeaderComponent implements OnInit { + /** + * Whether user is authenticated. + * @type {Observable} + */ + public isAuthenticated: Observable; public isNavBarCollapsed: Observable; + public showAuth = false; constructor( - private store: Store + private store: Store, + private windowService: HostWindowService ) { } ngOnInit(): void { + // set loading this.isNavBarCollapsed = this.store.select(navCollapsedSelector); } diff --git a/src/app/pagenotfound/pagenotfound.component.ts b/src/app/pagenotfound/pagenotfound.component.ts index bd119a4de9..e7923b3466 100644 --- a/src/app/pagenotfound/pagenotfound.component.ts +++ b/src/app/pagenotfound/pagenotfound.component.ts @@ -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'; @Component({ diff --git a/src/app/shared/auth-nav-menu/auth-nav-menu.component.html b/src/app/shared/auth-nav-menu/auth-nav-menu.component.html new file mode 100644 index 0000000000..cc9b8c410b --- /dev/null +++ b/src/app/shared/auth-nav-menu/auth-nav-menu.component.html @@ -0,0 +1,26 @@ + + + diff --git a/src/app/shared/auth-nav-menu/auth-nav-menu.component.scss b/src/app/shared/auth-nav-menu/auth-nav-menu.component.scss new file mode 100644 index 0000000000..a8c7b84f56 --- /dev/null +++ b/src/app/shared/auth-nav-menu/auth-nav-menu.component.scss @@ -0,0 +1,8 @@ +#loginDropdownMenu, #logoutDropdownMenu { + min-width: 330px; + z-index: 1002; +} + +#loginDropdownMenu { + min-height: 260px; +} diff --git a/src/app/shared/auth-nav-menu/auth-nav-menu.component.spec.ts b/src/app/shared/auth-nav-menu/auth-nav-menu.component.spec.ts new file mode 100644 index 0000000000..8b9f7c8775 --- /dev/null +++ b/src/app/shared/auth-nav-menu/auth-nav-menu.component.spec.ts @@ -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; + + 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) => { + 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) => { + 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) => { + 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) => { + 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) => { + 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) => { + 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) => { + const logoutDropdownMenu = deNavMenuItem.query(By.css('a[id=logoutLink]')); + expect(logoutDropdownMenu.nativeElement).toBeDefined(); + })); + }) + }) +}); diff --git a/src/app/shared/auth-nav-menu/auth-nav-menu.component.ts b/src/app/shared/auth-nav-menu/auth-nav-menu.component.ts new file mode 100644 index 0000000000..1c376258fb --- /dev/null +++ b/src/app/shared/auth-nav-menu/auth-nav-menu.component.ts @@ -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} + */ + public isAuthenticated: Observable; + + /** + * True if the authentication is loading. + * @type {boolean} + */ + public loading: Observable; + + public isXsOrSm$: Observable; + + public showAuth = Observable.of(false); + + public user: Observable; + + constructor(private store: Store, + 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); + }); + } +} diff --git a/src/app/shared/host-window.service.ts b/src/app/shared/host-window.service.ts index 13ecbe7538..ecbee685f1 100644 --- a/src/app/shared/host-window.service.ts +++ b/src/app/shared/host-window.service.ts @@ -92,4 +92,12 @@ export class HostWindowService { distinctUntilChanged() ); } + + isXsOrSm(): Observable { + return Observable.combineLatest( + this.isXs(), + this.isSm(), + ((isXs, isSm) => isXs || isSm) + ).distinctUntilChanged(); + } } diff --git a/src/app/shared/log-in/log-in.component.html b/src/app/shared/log-in/log-in.component.html new file mode 100644 index 0000000000..fe9a506e71 --- /dev/null +++ b/src/app/shared/log-in/log-in.component.html @@ -0,0 +1,28 @@ + + + + diff --git a/src/app/shared/log-in/log-in.component.scss b/src/app/shared/log-in/log-in.component.scss new file mode 100644 index 0000000000..5e4393edaf --- /dev/null +++ b/src/app/shared/log-in/log-in.component.scss @@ -0,0 +1,15 @@ +@import '../../../styles/variables.scss'; + +.form-login .form-control:focus { + z-index: 2; +} +.form-login input[type="email"] { + margin-bottom: -1px; + border-bottom-right-radius: 0; + border-bottom-left-radius: 0; +} +.form-login input[type="password"] { + border-top-left-radius: 0; + border-top-right-radius: 0; +} + diff --git a/src/app/shared/log-in/log-in.component.spec.ts b/src/app/shared/log-in/log-in.component.spec.ts new file mode 100644 index 0000000000..dc4a0be1c6 --- /dev/null +++ b/src/app/shared/log-in/log-in.component.spec.ts @@ -0,0 +1,127 @@ +import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; +import { async, ComponentFixture, inject, TestBed } from '@angular/core/testing'; +import { FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'; + +import { By } from '@angular/platform-browser'; +import { Store, StoreModule } from '@ngrx/store'; + +import { LogInComponent } from './log-in.component'; +import { authReducer } from '../../core/auth/auth.reducer'; +import { EpersonMock } from '../testing/eperson-mock'; +import { Eperson } from '../../core/eperson/models/eperson.model'; +import { TranslateModule } from '@ngx-translate/core'; +import { AuthService } from '../../core/auth/auth.service'; +import { AuthServiceStub } from '../testing/auth-service-stub'; +import { AppState } from '../../app.reducer'; + +describe('LogInComponent', () => { + + let component: LogInComponent; + let fixture: ComponentFixture; + let page: Page; + let user: Eperson; + + const authState = { + authenticated: false, + loaded: false, + loading: false, + }; + + beforeEach(() => { + user = EpersonMock; + }); + + beforeEach(async(() => { + // refine the test module by declaring the test component + TestBed.configureTestingModule({ + imports: [ + FormsModule, + ReactiveFormsModule, + StoreModule.forRoot(authReducer), + TranslateModule.forRoot() + ], + declarations: [ + LogInComponent + ], + providers: [ + {provide: AuthService, useClass: AuthServiceStub} + ], + schemas: [ + CUSTOM_ELEMENTS_SCHEMA + ] + }) + .compileComponents(); + + })); + + beforeEach(inject([Store], (store: Store) => { + store + .subscribe((state) => { + (state as any).core = Object.create({}); + (state as any).core.auth = authState; + }); + + // create component and test fixture + fixture = TestBed.createComponent(LogInComponent); + + // get test component from the fixture + component = fixture.componentInstance; + + // create page + page = new Page(component, fixture); + + // verify the fixture is stable (no pending tasks) + fixture.whenStable().then(() => { + page.addPageElements(); + }); + + })); + + it('should create a FormGroup comprised of FormControls', () => { + fixture.detectChanges(); + expect(component.form instanceof FormGroup).toBe(true); + }); + + it('should authenticate', () => { + fixture.detectChanges(); + + // set FormControl values + component.form.controls.email.setValue('user'); + component.form.controls.password.setValue('password'); + + // submit form + component.submit(); + + // verify Store.dispatch() is invoked + expect(page.navigateSpy.calls.any()).toBe(true, 'Store.dispatch not invoked'); + }); +}); + +/** + * I represent the DOM elements and attach spies. + * + * @class Page + */ +class Page { + + public emailInput: HTMLInputElement; + public navigateSpy: jasmine.Spy; + public passwordInput: HTMLInputElement; + + constructor(private component: LogInComponent, private fixture: ComponentFixture) { + // use injector to get services + const injector = fixture.debugElement.injector; + const store = injector.get(Store); + + // add spies + this.navigateSpy = spyOn(store, 'dispatch'); + } + + public addPageElements() { + const emailInputSelector = 'input[formcontrolname=\'email\']'; + this.emailInput = this.fixture.debugElement.query(By.css(emailInputSelector)).nativeElement; + + const passwordInputSelector = 'input[formcontrolname=\'password\']'; + this.passwordInput = this.fixture.debugElement.query(By.css(passwordInputSelector)).nativeElement; + } +} diff --git a/src/app/shared/log-in/log-in.component.ts b/src/app/shared/log-in/log-in.component.ts new file mode 100644 index 0000000000..3364b1067d --- /dev/null +++ b/src/app/shared/log-in/log-in.component.ts @@ -0,0 +1,184 @@ +import { Component, OnDestroy, OnInit } from '@angular/core'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; + +import { Store } from '@ngrx/store'; +import { Observable } from 'rxjs/Observable'; +import 'rxjs/add/operator/filter'; +import 'rxjs/add/operator/takeWhile'; + +import { AuthenticateAction, ResetAuthenticationMessagesAction } from '../../core/auth/auth.actions'; + +import { + getAuthenticationError, + getAuthenticationInfo, + isAuthenticated, + isAuthenticationLoading, +} from '../../core/auth/selectors'; +import { CoreState } from '../../core/core.reducers'; + +import { isNotEmpty } from '../empty.util'; +import { fadeOut } from '../animations/fade'; +import { AuthService } from '../../core/auth/auth.service'; + +/** + * /users/sign-in + * @class LogInComponent + */ +@Component({ + selector: 'ds-log-in', + templateUrl: './log-in.component.html', + styleUrls: ['./log-in.component.scss'], + animations: [fadeOut] +}) +export class LogInComponent implements OnDestroy, OnInit { + + /** + * The error if authentication fails. + * @type {Observable} + */ + public error: Observable; + + /** + * Has authentication error. + * @type {boolean} + */ + public hasError = false; + + /** + * The authentication info message. + * @type {Observable} + */ + public message: Observable; + + /** + * Has authentication message. + * @type {boolean} + */ + public hasMessage = false; + + /** + * Whether user is authenticated. + * @type {Observable} + */ + public isAuthenticated: Observable; + + /** + * True if the authentication is loading. + * @type {boolean} + */ + public loading: Observable; + + /** + * The authentication form. + * @type {FormGroup} + */ + public form: FormGroup; + + /** + * Component state. + * @type {boolean} + */ + private alive = true; + + /** + * @constructor + * @param {AuthService} authService + * @param {FormBuilder} formBuilder + * @param {Store} store + */ + constructor( + private authService: AuthService, + private formBuilder: FormBuilder, + private store: Store + ) { + } + + /** + * Lifecycle hook that is called after data-bound properties of a directive are initialized. + * @method ngOnInit + */ + public ngOnInit() { + // set isAuthenticated + this.isAuthenticated = this.store.select(isAuthenticated); + + // set formGroup + this.form = this.formBuilder.group({ + email: ['', Validators.required], + password: ['', Validators.required] + }); + + // set error + this.error = this.store.select(getAuthenticationError) + .map((error) => { + this.hasError = (isNotEmpty(error)); + return error; + }); + + // set error + this.message = this.store.select(getAuthenticationInfo) + .map((message) => { + this.hasMessage = (isNotEmpty(message)); + return message; + }); + + // set loading + this.loading = this.store.select(isAuthenticationLoading); + + // subscribe to success + this.store.select(isAuthenticated) + .takeWhile(() => this.alive) + .filter((authenticated) => authenticated) + .subscribe(() => { + this.authService.redirectToPreviousUrl(); + }); + } + + /** + * Lifecycle hook that is called when a directive, pipe or service is destroyed. + * @method ngOnDestroy + */ + public ngOnDestroy() { + this.alive = false; + } + + /** + * Reset error or message. + */ + public resetErrorOrMessage() { + if (this.hasError || this.hasMessage) { + this.store.dispatch(new ResetAuthenticationMessagesAction()); + this.hasError = false; + this.hasMessage = false; + } + } + + /** + * To the registration page. + * @method register + */ + public register() { + // TODO enable after registration process is done + // this.router.navigate(['/register']); + } + + /** + * Submit the authentication form. + * @method submit + */ + public submit() { + this.resetErrorOrMessage(); + // get email and password values + const email: string = this.form.get('email').value; + const password: string = this.form.get('password').value; + + // trim values + email.trim(); + password.trim(); + + // dispatch AuthenticationAction + this.store.dispatch(new AuthenticateAction(email, password)); + + // clear form + this.form.reset(); + } +} diff --git a/src/app/shared/log-out/log-out.component.html b/src/app/shared/log-out/log-out.component.html new file mode 100644 index 0000000000..f3ceae0087 --- /dev/null +++ b/src/app/shared/log-out/log-out.component.html @@ -0,0 +1,7 @@ + + diff --git a/src/app/shared/log-out/log-out.component.scss b/src/app/shared/log-out/log-out.component.scss new file mode 100644 index 0000000000..dcd67e092f --- /dev/null +++ b/src/app/shared/log-out/log-out.component.scss @@ -0,0 +1 @@ +@import '../log-in/log-in.component.scss'; diff --git a/src/app/shared/log-out/log-out.component.spec.ts b/src/app/shared/log-out/log-out.component.spec.ts new file mode 100644 index 0000000000..ad609f0aea --- /dev/null +++ b/src/app/shared/log-out/log-out.component.spec.ts @@ -0,0 +1,108 @@ +import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; +import { async, ComponentFixture, inject, TestBed } from '@angular/core/testing'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; + +import { Store, StoreModule } from '@ngrx/store'; + +import { authReducer } from '../../core/auth/auth.reducer'; +import { EpersonMock } from '../testing/eperson-mock'; +import { Eperson } from '../../core/eperson/models/eperson.model'; +import { TranslateModule } from '@ngx-translate/core'; +import { Router } from '@angular/router'; +import { AppState } from '../../app.reducer'; +import { LogOutComponent } from './log-out.component'; +import { RouterStub } from '../testing/router-stub'; + +describe('LogOutComponent', () => { + + let component: LogOutComponent; + let fixture: ComponentFixture; + let page: Page; + let user: Eperson; + + const authState = { + authenticated: false, + loaded: false, + loading: false, + }; + const routerStub = new RouterStub(); + + beforeEach(() => { + user = EpersonMock; + }); + + beforeEach(async(() => { + // refine the test module by declaring the test component + TestBed.configureTestingModule({ + imports: [ + FormsModule, + ReactiveFormsModule, + StoreModule.forRoot(authReducer), + TranslateModule.forRoot() + ], + declarations: [ + LogOutComponent + ], + providers: [ + {provide: Router, useValue: routerStub}, + ], + schemas: [ + CUSTOM_ELEMENTS_SCHEMA + ] + }) + .compileComponents(); + + })); + + beforeEach(inject([Store], (store: Store) => { + store + .subscribe((state) => { + (state as any).core = Object.create({}); + (state as any).core.auth = authState; + }); + + // create component and test fixture + fixture = TestBed.createComponent(LogOutComponent); + + // get test component from the fixture + component = fixture.componentInstance; + + // create page + page = new Page(component, fixture); + + })); + + it('should create an instance', () => { + expect(component).toBeTruthy(); + }); + + it('should log out', () => { + fixture.detectChanges(); + + // submit form + component.logOut(); + + // verify Store.dispatch() is invoked + expect(page.navigateSpy.calls.any()).toBe(true, 'Store.dispatch not invoked'); + }); +}); + +/** + * I represent the DOM elements and attach spies. + * + * @class Page + */ +class Page { + + public navigateSpy: jasmine.Spy; + + constructor(private component: LogOutComponent, private fixture: ComponentFixture) { + // use injector to get services + const injector = fixture.debugElement.injector; + const store = injector.get(Store); + + // add spies + this.navigateSpy = spyOn(store, 'dispatch'); + } + +} diff --git a/src/app/shared/log-out/log-out.component.ts b/src/app/shared/log-out/log-out.component.ts new file mode 100644 index 0000000000..37d0b142f9 --- /dev/null +++ b/src/app/shared/log-out/log-out.component.ts @@ -0,0 +1,82 @@ +import { Component, OnDestroy, OnInit } from '@angular/core'; +import { Router } from '@angular/router'; + +// @ngrx +import { Store } from '@ngrx/store'; + +// actions +import { LogOutAction } from '../../core/auth/auth.actions'; + +// reducers +import { + getLogOutError, + isAuthenticated, + isAuthenticationLoading, +} from '../../core/auth/selectors'; + +import { AppState } from '../../app.reducer'; +import { Observable } from 'rxjs/Observable'; +import { fadeOut } from '../animations/fade'; + +@Component({ + selector: 'ds-log-out', + templateUrl: './log-out.component.html', + styleUrls: ['./log-out.component.scss'], + animations: [fadeOut] +}) +export class LogOutComponent implements OnDestroy, OnInit { + /** + * The error if authentication fails. + * @type {Observable} + */ + public error: Observable; + + /** + * True if the logout is loading. + * @type {boolean} + */ + public loading: Observable; + + /** + * Component state. + * @type {boolean} + */ + private alive = true; + + /** + * @constructor + * @param {Store} store + */ + constructor(private router: Router, + private store: Store) { } + + /** + * Lifecycle hook that is called when a directive, pipe or service is destroyed. + */ + public ngOnDestroy() { + this.alive = false; + } + + /** + * Lifecycle hook that is called after data-bound properties of a directive are initialized. + */ + ngOnInit() { + // set error + this.error = this.store.select(getLogOutError); + + // set loading + this.loading = this.store.select(isAuthenticationLoading); + } + + /** + * Go to the home page. + */ + public home() { + this.router.navigate(['/home']); + } + + public logOut() { + this.store.dispatch(new LogOutAction()); + } + +} diff --git a/src/app/shared/mocks/mock-auth.service.ts b/src/app/shared/mocks/mock-auth.service.ts new file mode 100644 index 0000000000..6258e4aa21 --- /dev/null +++ b/src/app/shared/mocks/mock-auth.service.ts @@ -0,0 +1,6 @@ +/* tslint:disable:no-empty */ +export class AuthServiceMock { + public checksAuthenticationToken() { + return + } +} diff --git a/src/app/shared/pagination/pagination.component.ts b/src/app/shared/pagination/pagination.component.ts index faaf20ec79..3a1ba34e0c 100644 --- a/src/app/shared/pagination/pagination.component.ts +++ b/src/app/shared/pagination/pagination.component.ts @@ -207,8 +207,10 @@ export class PaginationComponent implements OnDestroy, OnInit { this.pageSizeOptions = this.paginationOptions.pageSizeOptions; this.currentPage = this.paginationOptions.currentPage; this.pageSize = this.paginationOptions.pageSize; - this.sortDirection = this.sortOptions.direction; - this.sortField = this.sortOptions.field; + if (this.sortOptions) { + this.sortDirection = this.sortOptions.direction; + this.sortField = this.sortOptions.field; + } this.currentQueryParams = { pageId: this.id, page: this.currentPage, diff --git a/src/app/shared/api.service.ts b/src/app/shared/services/api.service.ts similarity index 100% rename from src/app/shared/api.service.ts rename to src/app/shared/services/api.service.ts diff --git a/src/app/shared/services/client-cookie.service.ts b/src/app/shared/services/client-cookie.service.ts new file mode 100644 index 0000000000..4aa670ca78 --- /dev/null +++ b/src/app/shared/services/client-cookie.service.ts @@ -0,0 +1,25 @@ +import { Injectable } from '@angular/core' +import { CookieAttributes, getJSON, remove, set } from 'js-cookie' +import { CookieService, ICookieService } from './cookie.service'; + +@Injectable() +export class ClientCookieService extends CookieService implements ICookieService { + + public set(name: string, value: any, options?: CookieAttributes): void { + set(name, value, options); + this.updateSource() + } + + public remove(name: string, options?: CookieAttributes): void { + remove(name, options); + this.updateSource() + } + + public get(name: string): any { + return getJSON(name) + } + + public getAll(): any { + return getJSON() + } +} diff --git a/src/app/shared/services/cookie.service.spec.ts b/src/app/shared/services/cookie.service.spec.ts new file mode 100644 index 0000000000..dddb8f095f --- /dev/null +++ b/src/app/shared/services/cookie.service.spec.ts @@ -0,0 +1,28 @@ +import { CookieService, ICookieService } from './cookie.service' +import { async, TestBed } from '@angular/core/testing' +import { REQUEST } from '@nguniversal/express-engine/tokens' + +describe(CookieService.name, () => { + let service: ICookieService; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + providers: [ + CookieService, + {provide: REQUEST, useValue: {}} + ] + }) + })); + + beforeEach(() => { + service = TestBed.get(CookieService) + }); + + afterEach(() => { + TestBed.resetTestingModule() + }); + + it('should construct', async(() => { + expect(service).toBeDefined() + })) +}); diff --git a/src/app/shared/services/cookie.service.ts b/src/app/shared/services/cookie.service.ts new file mode 100644 index 0000000000..8265651d81 --- /dev/null +++ b/src/app/shared/services/cookie.service.ts @@ -0,0 +1,40 @@ +import { Inject, Injectable } from '@angular/core' + +import { REQUEST } from '@nguniversal/express-engine/tokens' + +import { Subject } from 'rxjs/Subject' +import { Observable } from 'rxjs/Observable' +import { CookieAttributes } from 'js-cookie' + +export interface ICookieService { + readonly cookies$: Observable<{ readonly [key: string]: any }> + + getAll(): any + + get(name: string): any + + set(name: string, value: any, options?: CookieAttributes): void + + remove(name: string, options?: CookieAttributes): void +} + +@Injectable() +export abstract class CookieService implements ICookieService { + protected readonly cookieSource = new Subject<{ readonly [key: string]: any }>(); + public readonly cookies$ = this.cookieSource.asObservable(); + + constructor(@Inject(REQUEST) protected req: any) { + } + + public abstract set(name: string, value: any, options?: CookieAttributes): void + + public abstract remove(name: string, options?: CookieAttributes): void + + public abstract get(name: string): any + + public abstract getAll(): any + + protected updateSource() { + this.cookieSource.next(this.getAll()); + } +} diff --git a/src/app/shared/route.service.spec.ts b/src/app/shared/services/route.service.spec.ts similarity index 100% rename from src/app/shared/route.service.spec.ts rename to src/app/shared/services/route.service.spec.ts diff --git a/src/app/shared/route.service.ts b/src/app/shared/services/route.service.ts similarity index 96% rename from src/app/shared/route.service.ts rename to src/app/shared/services/route.service.ts index 9c2b64ede1..aa683a6403 100644 --- a/src/app/shared/route.service.ts +++ b/src/app/shared/services/route.service.ts @@ -4,7 +4,7 @@ import { ActivatedRoute, convertToParamMap, NavigationExtras, Params, Router, } from '@angular/router'; -import { isNotEmpty } from './empty.util'; +import { isNotEmpty } from '../empty.util'; @Injectable() export class RouteService { diff --git a/src/app/shared/services/server-cookie.service.ts b/src/app/shared/services/server-cookie.service.ts new file mode 100644 index 0000000000..49cc738346 --- /dev/null +++ b/src/app/shared/services/server-cookie.service.ts @@ -0,0 +1,29 @@ +import { Injectable } from '@angular/core' +import { CookieAttributes } from 'js-cookie' +import { CookieService, ICookieService } from './cookie.service'; + +@Injectable() +export class ServerCookieService extends CookieService implements ICookieService { + + public set(name: string, value: any, options?: CookieAttributes): void { + return + } + + public remove(name: string, options?: CookieAttributes): void { + return + } + + public get(name: string): any { + try { + return JSON.parse(this.req.cookies[name]) + } catch (err) { + return this.req ? this.req.cookies[name] : undefined + } + } + + public getAll(): any { + if (this.req) { + return this.req.cookies + } + } +} diff --git a/src/app/shared/server-response.service.ts b/src/app/shared/services/server-response.service.ts similarity index 100% rename from src/app/shared/server-response.service.ts rename to src/app/shared/services/server-response.service.ts diff --git a/src/app/shared/window.service.ts b/src/app/shared/services/window.service.ts similarity index 100% rename from src/app/shared/window.service.ts rename to src/app/shared/services/window.service.ts diff --git a/src/app/shared/shared.module.ts b/src/app/shared/shared.module.ts index dff55ebb5c..57ba7dec4d 100644 --- a/src/app/shared/shared.module.ts +++ b/src/app/shared/shared.module.ts @@ -44,6 +44,9 @@ import { SearchResultGridElementComponent } from './object-grid/search-result-gr import { ViewModeSwitchComponent } from './view-mode-switch/view-mode-switch.component'; import { GridThumbnailComponent } from './object-grid/grid-thumbnail/grid-thumbnail.component'; import { VarDirective } from './utils/var.directive'; +import { LogInComponent } from './log-in/log-in.component'; +import { AuthNavMenuComponent } from './auth-nav-menu/auth-nav-menu.component'; +import { LogOutComponent } from './log-out/log-out.component'; import { FormComponent } from './form/form.component'; import { DsDynamicTypeaheadComponent } from './form/builder/ds-dynamic-form-ui/models/typeahead/dynamic-typeahead.component'; import { DsDynamicScrollableDropdownComponent } from './form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component'; @@ -101,6 +104,7 @@ const PIPES = [ const COMPONENTS = [ // put shared components here + AuthNavMenuComponent, ChipsComponent, ComcolPageContentComponent, ComcolPageHeaderComponent, @@ -117,6 +121,8 @@ const COMPONENTS = [ ErrorComponent, FormComponent, LoadingComponent, + LogInComponent, + LogOutComponent, NumberPickerComponent, ObjectListComponent, AbstractListableElementComponent, diff --git a/src/app/shared/testing/auth-request-service-stub.ts b/src/app/shared/testing/auth-request-service-stub.ts new file mode 100644 index 0000000000..2c47068af4 --- /dev/null +++ b/src/app/shared/testing/auth-request-service-stub.ts @@ -0,0 +1,69 @@ +import { Observable } from 'rxjs/Observable'; +import { HttpOptions } from '../../core/dspace-rest-v2/dspace-rest-v2.service'; +import { AuthStatus } from '../../core/auth/models/auth-status.model'; +import { AuthTokenInfo } from '../../core/auth/models/auth-token-info.model'; +import { Eperson } from '../../core/eperson/models/eperson.model'; +import { isNotEmpty } from '../empty.util'; +import { EpersonMock } from './eperson-mock'; + +export class AuthRequestServiceStub { + protected mockUser: Eperson = EpersonMock; + protected mockTokenInfo = new AuthTokenInfo('test_token'); + + public postToEndpoint(method: string, body: any, options?: HttpOptions): Observable { + const authStatusStub: AuthStatus = new AuthStatus(); + if (isNotEmpty(body)) { + const parsedBody = this.parseQueryString(body); + authStatusStub.okay = true; + if (parsedBody.user === 'user' && parsedBody.password === 'password') { + authStatusStub.authenticated = true; + authStatusStub.token = this.mockTokenInfo; + } else { + authStatusStub.authenticated = false; + } + } else { + const token = (options.headers as any).lazyUpdate[1].value; + if (this.validateToken(token)) { + authStatusStub.authenticated = true; + authStatusStub.token = this.mockTokenInfo; + authStatusStub.eperson = [this.mockUser]; + } else { + authStatusStub.authenticated = false; + } + } + return Observable.of(authStatusStub); + } + + public getRequest(method: string, options?: HttpOptions): Observable { + const authStatusStub: AuthStatus = new AuthStatus(); + switch (method) { + case 'logout': + authStatusStub.authenticated = false; + break; + case 'status': + const token = (options.headers as any).lazyUpdate[1].value; + if (this.validateToken(token)) { + authStatusStub.authenticated = true; + authStatusStub.token = this.mockTokenInfo; + authStatusStub.eperson = [this.mockUser]; + } else { + authStatusStub.authenticated = false; + } + break; + } + return Observable.of(authStatusStub); + } + + private validateToken(token): boolean { + return (token === 'Bearer test_token'); + } + private parseQueryString(query): any { + const obj = Object.create({}); + const vars = query.split('&'); + for (const param of vars) { + const pair = param.split('='); + obj[pair[0]] = pair[1] + } + return obj; + } +} diff --git a/src/app/shared/testing/auth-service-stub.ts b/src/app/shared/testing/auth-service-stub.ts new file mode 100644 index 0000000000..c7d5556910 --- /dev/null +++ b/src/app/shared/testing/auth-service-stub.ts @@ -0,0 +1,95 @@ +import { AuthStatus } from '../../core/auth/models/auth-status.model'; +import { Observable } from 'rxjs/Observable'; +import { AuthTokenInfo } from '../../core/auth/models/auth-token-info.model'; +import { EpersonMock } from './eperson-mock'; +import { Eperson } from '../../core/eperson/models/eperson.model'; + +export class AuthServiceStub { + + token: AuthTokenInfo = new AuthTokenInfo('token_test'); + private _tokenExpired = false; + + constructor() { + this.token.expires = Date.now() + (1000 * 60 * 60); + } + + public authenticate(user: string, password: string): Observable { + if (user === 'user' && password === 'password') { + const authStatus = new AuthStatus(); + authStatus.okay = true; + authStatus.authenticated = true; + authStatus.token = this.token; + authStatus.eperson = [EpersonMock]; + return Observable.of(authStatus); + } else { + console.log('error'); + throw(new Error('Message Error test')); + } + } + + public authenticatedUser(token: AuthTokenInfo): Observable { + if (token.accessToken === 'token_test') { + return Observable.of(EpersonMock); + } else { + throw(new Error('Message Error test')); + } + } + + public buildAuthHeader(token?: AuthTokenInfo): string { + return `Bearer ${token.accessToken}`; + } + + public getToken(): AuthTokenInfo { + return this.token; + } + + public hasValidAuthenticationToken(): Observable { + return Observable.of(this.token); + } + + public logout(): Observable { + return Observable.of(true); + } + + public isTokenExpired(token?: AuthTokenInfo): boolean { + return this._tokenExpired; + } + + /** + * This method is used to ease testing + */ + public setTokenAsExpired() { + this._tokenExpired = true + } + + /** + * This method is used to ease testing + */ + public setTokenAsNotExpired() { + this._tokenExpired = false + } + + public isTokenExpiring(): Observable { + return Observable.of(false); + } + + public refreshAuthenticationToken(token: AuthTokenInfo): Observable { + return Observable.of(this.token); + } + + public redirectToPreviousUrl() { + return; + } + + public removeToken() { + return; + } + + setRedirectUrl(url: string) { + return; + } + + public storeToken(token: AuthTokenInfo) { + return; + } +} diff --git a/src/app/shared/testing/eperson-mock.ts b/src/app/shared/testing/eperson-mock.ts new file mode 100644 index 0000000000..9cf938fcf2 --- /dev/null +++ b/src/app/shared/testing/eperson-mock.ts @@ -0,0 +1,34 @@ +import { Eperson } from '../../core/eperson/models/eperson.model'; + +export const EpersonMock: Eperson = Object.assign(new Eperson(),{ + handle: null, + groups: [], + netid: 'test@test.com', + lastActive: '2018-05-14T12:25:42.411+0000', + canLogIn: true, + email: 'test@test.com', + requireCertificate: false, + selfRegistered: false, + self: 'https://dspace.4science.it/dspace-spring-rest/api/eperson/epersons/testid', + id: 'testid', + uuid: 'testid', + type: 'eperson', + name: 'User Test', + metadata: [ + { + key: 'eperson.firstname', + language: null, + value: 'User' + }, + { + key: 'eperson.lastname', + language: null, + value: 'Test' + }, + { + key: 'eperson.language', + language: null, + value: 'en' + } + ] +}); diff --git a/src/app/shared/testing/host-window-service-stub.ts b/src/app/shared/testing/host-window-service-stub.ts index 98af7fda18..2833415477 100644 --- a/src/app/shared/testing/host-window-service-stub.ts +++ b/src/app/shared/testing/host-window-service-stub.ts @@ -16,4 +16,8 @@ export class HostWindowServiceStub { isXs(): Observable { return Observable.of(this.width < 576); } + + isXsOrSm(): Observable { + return this.isXs(); + } } diff --git a/src/app/shared/testing/platform-service-stub.ts b/src/app/shared/testing/platform-service-stub.ts new file mode 100644 index 0000000000..39b9b5f4d3 --- /dev/null +++ b/src/app/shared/testing/platform-service-stub.ts @@ -0,0 +1,12 @@ + +// declare a stub service +export class PlatformServiceStub { + + public get isBrowser(): boolean { + return true; + } + + public get isServer(): boolean { + return false; + } +} diff --git a/src/app/shared/truncatable/truncatable-part/truncatable-part.component.scss b/src/app/shared/truncatable/truncatable-part/truncatable-part.component.scss index ac3641df39..133328fec1 100644 --- a/src/app/shared/truncatable/truncatable-part/truncatable-part.component.scss +++ b/src/app/shared/truncatable/truncatable-part/truncatable-part.component.scss @@ -23,6 +23,7 @@ max-width: 150px; height: $height; background: linear-gradient(to right, rgba(255, 255, 255, 0), $body-bg 70%); + pointer-events: none; } } } @@ -60,4 +61,4 @@ $h4-factor: strip-unit($h4-font-size); } } } -} \ No newline at end of file +} diff --git a/src/app/shared/utils/encode-decode.util.spec.ts b/src/app/shared/utils/encode-decode.util.spec.ts new file mode 100644 index 0000000000..c3039c482e --- /dev/null +++ b/src/app/shared/utils/encode-decode.util.spec.ts @@ -0,0 +1,10 @@ +import { Base64EncodeUrl } from './encode-decode.util'; + +describe('Encode/Decode Utils', () => { + const strng = '+string+/=t-'; + const encodedStrng = '%2Bstring%2B%2F%3Dt-'; + + it('should return encoded string', () => { + expect(Base64EncodeUrl(strng)).toBe(encodedStrng); + }); +}); diff --git a/src/app/shared/utils/encode-decode.util.ts b/src/app/shared/utils/encode-decode.util.ts new file mode 100644 index 0000000000..e21034b7bd --- /dev/null +++ b/src/app/shared/utils/encode-decode.util.ts @@ -0,0 +1,10 @@ +/** + * use this to make a Base64 encoded string URL friendly, + * i.e. '+' and '/' are replaced with special percent-encoded hexadecimal sequences + * + * @param {String} str the encoded string + * @returns {String} the URL friendly encoded String + */ +export function Base64EncodeUrl(str): string { + return str.replace(/\+/g, '%2B').replace(/\//g, '%2F').replace(/\=/g, '%3D'); +} diff --git a/src/modules/app/browser-app.module.ts b/src/modules/app/browser-app.module.ts index 1112ebdb23..a7a59dc837 100644 --- a/src/modules/app/browser-app.module.ts +++ b/src/modules/app/browser-app.module.ts @@ -1,8 +1,9 @@ import { HttpClient, HttpClientModule } from '@angular/common/http'; import { NgModule } from '@angular/core'; -import { BrowserModule } from '@angular/platform-browser'; +import { BrowserModule, makeStateKey, TransferState } from '@angular/platform-browser'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { RouterModule } from '@angular/router'; +import { REQUEST } from '@nguniversal/express-engine/tokens'; import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; import { TranslateHttpLoader } from '@ngx-translate/http-loader'; @@ -14,13 +15,22 @@ import { AppComponent } from '../../app/app.component'; import { AppModule } from '../../app/app.module'; import { DSpaceBrowserTransferStateModule } from '../transfer-state/dspace-browser-transfer-state.module'; import { DSpaceTransferState } from '../transfer-state/dspace-transfer-state.service'; +import { ClientCookieService } from '../../app/shared/services/client-cookie.service'; +import { CookieService } from '../../app/shared/services/cookie.service'; +import { AuthService } from '../../app/core/auth/auth.service'; import { Angulartics2Module } from 'angulartics2'; import { Angulartics2GoogleAnalytics } from 'angulartics2/ga'; +export const REQ_KEY = makeStateKey('req'); + export function createTranslateLoader(http: HttpClient) { return new TranslateHttpLoader(http, 'assets/i18n/', '.json'); } +export function getRequest(transferState: TransferState): any { + return transferState.get(REQ_KEY, {}) +} + @NgModule({ bootstrap: [AppComponent], imports: [ @@ -48,6 +58,21 @@ export function createTranslateLoader(http: HttpClient) { }), AppModule ], + providers: [ + { + provide: REQUEST, + useFactory: getRequest, + deps: [TransferState] + }, + { + provide: AuthService, + useClass: AuthService + }, + { + provide: CookieService, + useClass: ClientCookieService + } + ] }) export class BrowserAppModule { constructor( diff --git a/src/modules/app/server-app.module.ts b/src/modules/app/server-app.module.ts index fac1b63ada..10285e75f5 100644 --- a/src/modules/app/server-app.module.ts +++ b/src/modules/app/server-app.module.ts @@ -15,6 +15,10 @@ import { DSpaceServerTransferStateModule } from '../transfer-state/dspace-server import { DSpaceTransferState } from '../transfer-state/dspace-transfer-state.service'; import { TranslateUniversalLoader } from '../translate-universal-loader'; +import { CookieService } from '../../app/shared/services/cookie.service'; +import { ServerCookieService } from '../../app/shared/services/server-cookie.service'; +import { AuthService } from '../../app/core/auth/auth.service'; +import { ServerAuthService } from '../../app/core/auth/server-auth.service'; import { Angulartics2GoogleAnalytics } from 'angulartics2/ga'; import { AngularticsMock } from '../../app/shared/mocks/mock-angulartics.service'; @@ -45,7 +49,15 @@ export function createTranslateLoader() { AppModule ], providers: [ - { provide: Angulartics2GoogleAnalytics, useClass: AngularticsMock } + { provide: Angulartics2GoogleAnalytics, useClass: AngularticsMock }, + { + provide: AuthService, + useClass: ServerAuthService + }, + { + provide: CookieService, + useClass: ServerCookieService + } ] }) export class ServerAppModule { diff --git a/src/routes.ts b/src/routes.ts index f051b16198..392d3925a5 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -3,6 +3,8 @@ export const ROUTES: string[] = [ 'items/:id', 'collections/:id', 'communities/:id', + 'login', + 'logout', 'search', '**' ]; diff --git a/src/styles/_custom_variables.scss b/src/styles/_custom_variables.scss index 8372aa934d..f378c2b7c9 100644 --- a/src/styles/_custom_variables.scss +++ b/src/styles/_custom_variables.scss @@ -6,4 +6,6 @@ $card-thumbnail-height:240px; $dropdown-menu-max-height: 200px; $drop-zone-area-z-index: 1025; $drop-zone-area-inner-z-index: 1021; +$login-logo-height:72px; +$login-logo-width:72px; diff --git a/tsconfig.json b/tsconfig.json index 8ab72a4327..8037c659ed 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -26,8 +26,7 @@ "es6", "es2015", "es2016", - "es2017", - "es2017.object" + "es2017" ] }, "exclude": [ diff --git a/yarn.lock b/yarn.lock index 8c2d39acd1..3207959415 100644 --- a/yarn.lock +++ b/yarn.lock @@ -215,6 +215,10 @@ version "2.8.6" resolved "https://registry.yarnpkg.com/@types/jasmine/-/jasmine-2.8.6.tgz#14445b6a1613cf4e05dd61c3c3256d0e95c0421e" +"@types/js-cookie@2.1.0": + version "2.1.0" + resolved "https://registry.yarnpkg.com/@types/js-cookie/-/js-cookie-2.1.0.tgz#a8916246aa994db646c66d54c854916213300a51" + "@types/lodash@4.14.74": version "4.14.74" resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.74.tgz#ac3bd8db988e7f7038e5d22bd76a7ba13f876168" @@ -4498,6 +4502,10 @@ js-base64@^2.1.8, js-base64@^2.1.9: version "2.3.2" resolved "https://registry.yarnpkg.com/js-base64/-/js-base64-2.3.2.tgz#a79a923666372b580f8e27f51845c6f7e8fbfbaf" +js-cookie@2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/js-cookie/-/js-cookie-2.2.0.tgz#1b2c279a6eece380a12168b92485265b35b1effb" + js-tokens@^3.0.0, js-tokens@^3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-3.0.2.tgz#9866df395102130e38f7f996bceb65443209c25b" @@ -4619,6 +4627,10 @@ jszip@^3.1.3: pako "~1.0.2" readable-stream "~2.0.6" +jwt-decode@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/jwt-decode/-/jwt-decode-2.2.0.tgz#7d86bd56679f58ce6a84704a657dd392bba81a79" + karma-chrome-launcher@2.2.0: version "2.2.0" resolved "https://registry.yarnpkg.com/karma-chrome-launcher/-/karma-chrome-launcher-2.2.0.tgz#cf1b9d07136cc18fe239327d24654c3dbc368acf"