From 2309c1be3b895f107e1e9d378f13145c9274b334 Mon Sep 17 00:00:00 2001 From: Giuseppe Digilio Date: Thu, 1 Feb 2018 17:13:08 +0100 Subject: [PATCH 01/80] Added login page ang nav menu --- resources/i18n/en.json | 12 ++++++- .../+login-page/login-page-routing.module.ts | 13 ++++++++ src/app/+login-page/login-page.component.html | 5 +++ src/app/+login-page/login-page.component.scss | 15 +++++++++ src/app/+login-page/login-page.component.ts | 10 ++++++ src/app/+login-page/login-page.module.ts | 19 +++++++++++ src/app/app-routing.module.ts | 1 + src/app/app.module.ts | 2 ++ src/app/header/header.component.html | 13 ++++++++ src/app/header/header.component.ts | 10 ++++-- src/app/shared/host-window.service.ts | 6 ++++ .../login-form/login-form.component.html | 11 +++++++ .../login-form/login-form.component.scss | 32 +++++++++++++++++++ .../shared/login-form/login-form.component.ts | 10 ++++++ src/app/shared/shared.module.ts | 2 ++ 15 files changed, 158 insertions(+), 3 deletions(-) create mode 100644 src/app/+login-page/login-page-routing.module.ts create mode 100644 src/app/+login-page/login-page.component.html create mode 100644 src/app/+login-page/login-page.component.scss create mode 100644 src/app/+login-page/login-page.component.ts create mode 100644 src/app/+login-page/login-page.module.ts create mode 100644 src/app/shared/login-form/login-form.component.html create mode 100644 src/app/shared/login-form/login-form.component.scss create mode 100644 src/app/shared/login-form/login-form.component.ts diff --git a/resources/i18n/en.json b/resources/i18n/en.json index 7b3d31c3e0..f93c0e933e 100644 --- a/resources/i18n/en.json +++ b/resources/i18n/en.json @@ -46,7 +46,8 @@ } }, "nav": { - "home": "Home" + "home": "Home", + "login": "Log In" }, "pagination": { "results-per-page": "Results Per Page", @@ -145,5 +146,14 @@ "item": "Error fetching item", "objects": "Error fetching objects", "search-results": "Error fetching search results" + }, + "login": { + "title": "Login", + "form": { + "header": "Please log in", + "email": "Email address", + "password": "Password", + "submit": "Log in" + } } } 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..5a37df9d2a --- /dev/null +++ b/src/app/+login-page/login-page.component.html @@ -0,0 +1,5 @@ +
+ +

{{"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..b71eee7ee1 --- /dev/null +++ b/src/app/+login-page/login-page.component.scss @@ -0,0 +1,15 @@ +.login-container { + height: 100%; + display: -ms-flexbox; + display: -webkit-box; + display: flex; + -ms-flex-align: center; + -ms-flex-pack: center; + -webkit-box-align: center; + align-items: center; + -webkit-box-pack: center; + justify-content: center; + padding-top: 40px; + padding-bottom: 40px; + background-color: #f5f5f5; +} 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..14bb443a4e --- /dev/null +++ b/src/app/+login-page/login-page.component.ts @@ -0,0 +1,10 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'ds-login-page', + styleUrls: ['./login-page.component.scss'], + templateUrl: './login-page.component.html' +}) +export class LoginPageComponent { + +} 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/app-routing.module.ts b/src/app/app-routing.module.ts index dc442cd485..c11881800e 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -12,6 +12,7 @@ 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: 'login', loadChildren: './+login-page/login-page.module#LoginPageModule' }, { path: '**', pathMatch: 'full', component: PageNotFoundComponent }, ]) ], diff --git a/src/app/app.module.ts b/src/app/app.module.ts index c2728941d0..0db1a0a1eb 100755 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -28,6 +28,7 @@ import { PageNotFoundComponent } from './pagenotfound/pagenotfound.component'; import { GLOBAL_CONFIG, ENV_CONFIG, GlobalConfig } from '../config'; import { DSpaceRouterStateSerializer } from './shared/ngrx/dspace-router-state-serializer'; +import { SharedModule } from './shared/shared.module'; export function getConfig() { return ENV_CONFIG; @@ -51,6 +52,7 @@ if (!ENV_CONFIG.production) { @NgModule({ imports: [ CommonModule, + SharedModule, HttpClientModule, AppRoutingModule, CoreModule.forRoot(), diff --git a/src/app/header/header.component.html b/src/app/header/header.component.html index 0cdb443b8a..ac76ea1dab 100644 --- a/src/app/header/header.component.html +++ b/src/app/header/header.component.html @@ -12,6 +12,19 @@ {{ 'nav.home' | translate }}(current) + diff --git a/src/app/header/header.component.ts b/src/app/header/header.component.ts index 624ae209dd..4be64221dc 100644 --- a/src/app/header/header.component.ts +++ b/src/app/header/header.component.ts @@ -5,6 +5,8 @@ import { Observable } from 'rxjs/Observable'; import { HeaderState } from './header.reducer'; import { HeaderToggleAction } from './header.actions'; import { AppState } from '../app.reducer'; +import { HostWindowService } from '../shared/host-window.service'; +import { fadeInOut } from '../shared/animations/fade'; const headerStateSelector = (state: AppState) => state.header; const navCollapsedSelector = createSelector(headerStateSelector, (header: HeaderState) => header.navCollapsed); @@ -12,13 +14,17 @@ const navCollapsedSelector = createSelector(headerStateSelector, (header: Header @Component({ selector: 'ds-header', styleUrls: ['header.component.scss'], - templateUrl: 'header.component.html' + templateUrl: 'header.component.html', + animations: [ + fadeInOut + ] }) export class HeaderComponent implements OnInit { public isNavBarCollapsed: Observable; constructor( - private store: Store + private store: Store, + private windowService: HostWindowService ) { } diff --git a/src/app/shared/host-window.service.ts b/src/app/shared/host-window.service.ts index 6fa5a6b32b..02214f57e1 100644 --- a/src/app/shared/host-window.service.ts +++ b/src/app/shared/host-window.service.ts @@ -60,4 +60,10 @@ export class HostWindowService { .map((width) => width >= GridBreakpoint.XL) .distinctUntilChanged(); } + + isMobileView(): Observable { + return this.getWidthObs() + .map((width) => width < GridBreakpoint.MD) + .distinctUntilChanged(); + } } diff --git a/src/app/shared/login-form/login-form.component.html b/src/app/shared/login-form/login-form.component.html new file mode 100644 index 0000000000..6dcc4d1a5c --- /dev/null +++ b/src/app/shared/login-form/login-form.component.html @@ -0,0 +1,11 @@ + + diff --git a/src/app/shared/login-form/login-form.component.scss b/src/app/shared/login-form/login-form.component.scss new file mode 100644 index 0000000000..e6226bc015 --- /dev/null +++ b/src/app/shared/login-form/login-form.component.scss @@ -0,0 +1,32 @@ +@import '../../../styles/variables.scss'; + +.form-login { + width: 100%; + max-width: 330px; + padding: 15px; + margin: 0 auto; +} +.form-login .checkbox { + font-weight: 400; +} +.form-login .form-control { + position: relative; + box-sizing: border-box; + height: auto; + padding: 10px; + font-size: 16px; +} +.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"] { + margin-bottom: 10px; + border-top-left-radius: 0; + border-top-right-radius: 0; +} + diff --git a/src/app/shared/login-form/login-form.component.ts b/src/app/shared/login-form/login-form.component.ts new file mode 100644 index 0000000000..39fb3a8936 --- /dev/null +++ b/src/app/shared/login-form/login-form.component.ts @@ -0,0 +1,10 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'ds-login-form', + styleUrls: ['./login-form.component.scss'], + templateUrl: './login-form.component.html' +}) +export class LoginFormComponent { + +} diff --git a/src/app/shared/shared.module.ts b/src/app/shared/shared.module.ts index ca13067851..2f5a266d9f 100644 --- a/src/app/shared/shared.module.ts +++ b/src/app/shared/shared.module.ts @@ -41,6 +41,7 @@ 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 { LoginFormComponent } from './login-form/login-form.component'; const MODULES = [ // Do NOT include UniversalModule, HttpModule, or JsonpModule here @@ -68,6 +69,7 @@ const COMPONENTS = [ ComcolPageLogoComponent, ErrorComponent, LoadingComponent, + LoginFormComponent, ObjectListComponent, AbstractListableElementComponent, WrapperListElementComponent, From 55c55dc682bd2c5a6d8b32ba0ecd751ea4648b94 Mon Sep 17 00:00:00 2001 From: Giuseppe Digilio Date: Fri, 2 Feb 2018 14:33:48 +0100 Subject: [PATCH 02/80] Added Eperson and group models --- .../eperson/models/NormalizedEperson.model.ts | 37 +++++++++++++++++++ .../eperson/models/NormalizedGroup.model.ts | 18 +++++++++ src/app/core/eperson/models/eperson.model.ts | 22 +++++++++++ src/app/core/eperson/models/group.model.ts | 8 ++++ src/app/core/shared/resource-type.ts | 2 + 5 files changed, 87 insertions(+) create mode 100644 src/app/core/eperson/models/NormalizedEperson.model.ts create mode 100644 src/app/core/eperson/models/NormalizedGroup.model.ts create mode 100644 src/app/core/eperson/models/eperson.model.ts create mode 100644 src/app/core/eperson/models/group.model.ts 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/shared/resource-type.ts b/src/app/core/shared/resource-type.ts index f3554e18cf..6f4a1c7358 100644 --- a/src/app/core/shared/resource-type.ts +++ b/src/app/core/shared/resource-type.ts @@ -9,4 +9,6 @@ export enum ResourceType { Item = 'item', Collection = 'collection', Community = 'community', + Eperson = 'eperson', + Group = 'group', } From 8f28429fe66ec904cd66a862746fceb1e46ecae3 Mon Sep 17 00:00:00 2001 From: Giuseppe Digilio Date: Fri, 2 Feb 2018 14:35:26 +0100 Subject: [PATCH 03/80] Added auth service --- src/app/core/auth/auth.service.ts | 102 ++++++++++++++++++ src/app/core/auth/authenticated.guard.ts | 60 +++++++++++ .../core/auth/authentication-status.model.ts | 9 ++ 3 files changed, 171 insertions(+) create mode 100644 src/app/core/auth/auth.service.ts create mode 100644 src/app/core/auth/authenticated.guard.ts create mode 100644 src/app/core/auth/authentication-status.model.ts diff --git a/src/app/core/auth/auth.service.ts b/src/app/core/auth/auth.service.ts new file mode 100644 index 0000000000..2ca53fd160 --- /dev/null +++ b/src/app/core/auth/auth.service.ts @@ -0,0 +1,102 @@ +import { Injectable } from '@angular/core'; + +import { Observable } from 'rxjs/Observable'; + +import { Eperson } from '../eperson/models/eperson.model'; + +export const MOCK_USER = new Eperson(); +MOCK_USER.id = '92a59227-ccf7-46da-9776-86c3fc64147f'; +MOCK_USER.uuid = '92a59227-ccf7-46da-9776-86c3fc64147f'; +MOCK_USER.name = 'andrea.bollini@4science.it'; +MOCK_USER.email = 'andrea.bollini@4science.it'; +MOCK_USER.metadata = [ + { + key: 'eperson.firstname', + value: 'Andrea', + language: null + }, + { + key: 'eperson.lastname', + value: 'Bollini', + language: null + }, + { + key: 'eperson.language', + value: 'en', + language: null + } +]; + +/** + * The user service. + */ +@Injectable() +export class AuthService { + + /** + * True if authenticated + * @type + */ + private _authenticated = false; + + /** + * Authenticate the user + * + * @param {string} email The user's email address + * @param {string} password The user's password + * @returns {Observable} The authenticated user observable. + */ + public authenticate(email: string, password: string): Observable { + // Normally you would do an HTTP request to determine to + // attempt authenticating the user using the supplied credentials. + + if (email === MOCK_USER.email && password === 'password') { + this._authenticated = true; + return Observable.of(MOCK_USER); + } + + return Observable.throw(new Error('Invalid email or password')); + } + + /** + * Determines if the user is authenticated + * @returns {Observable} + */ + public authenticated(): Observable { + return Observable.of(this._authenticated); + } + + /** + * Returns the authenticated user + * @returns {User} + */ + public authenticatedUser(): Observable { + // Normally you would do an HTTP request to determine if + // the user has an existing auth session on the server + // but, let's just return the mock user for this example. + return Observable.of(MOCK_USER); + } + + /** + * 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 signout(): Observable { + // Normally you would do an HTTP request sign end the session + // but, let's just return an observable of true. + this._authenticated = false; + return Observable.of(true); + } +} diff --git a/src/app/core/auth/authenticated.guard.ts b/src/app/core/auth/authenticated.guard.ts new file mode 100644 index 0000000000..3cbecf1215 --- /dev/null +++ b/src/app/core/auth/authenticated.guard.ts @@ -0,0 +1,60 @@ +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 { + isAuthenticated, + State +} from '../app.reducers'; + +/** + * Prevent unauthorized activating and loading of routes + * @class AuthenticatedGuard + */ +@Injectable() +export class AuthenticatedGuard implements CanActivate, CanLoad { + + /** + * @constructor + */ + constructor(private router: Router, private store: Store) {} + + /** + * True when user is authenticated + * @method canActivate + */ + canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable | boolean { + // get observable + const observable = this.store.select(isAuthenticated); + + // redirect to sign in page if user is not authenticated + observable.subscribe((authenticated) => { + if (!authenticated) { + this.router.navigate(['/login']); + } + }); + + return observable; + } + + /** + * True when user is authenticated + * @method canLoad + */ + canLoad(route: Route): Observable | Promise | boolean { + // get observable + const observable = this.store.select(isAuthenticated); + + // redirect to sign in page if user is not authenticated + observable.subscribe((authenticated) => { + if (!authenticated) { + this.router.navigate(['/login']); + } + }); + + return observable; + } +} diff --git a/src/app/core/auth/authentication-status.model.ts b/src/app/core/auth/authentication-status.model.ts new file mode 100644 index 0000000000..9ef027d600 --- /dev/null +++ b/src/app/core/auth/authentication-status.model.ts @@ -0,0 +1,9 @@ +import { DSpaceObject } from '../shared/dspace-object.model'; + +export class AuthenticationStatus extends DSpaceObject { + + okay: boolean; + + authenticated: boolean; + +} From 19c9482009ab190c28bc27ab50303087389fc662 Mon Sep 17 00:00:00 2001 From: Giuseppe Digilio Date: Tue, 6 Feb 2018 15:48:05 +0100 Subject: [PATCH 04/80] Added first release of the authentication module --- resources/i18n/en.json | 14 +- src/app/+home-page/home-page.component.html | 1 + src/app/+home-page/home-page.component.ts | 15 +- src/app/+login-page/login-page.component.html | 2 +- .../logout-page-routing.module.ts | 19 ++ .../+logout-page/logout-page.component.html | 5 + .../+logout-page/logout-page.component.scss | 15 ++ src/app/+logout-page/logout-page.component.ts | 10 + src/app/+logout-page/logout-page.module.ts | 19 ++ src/app/app-routing.module.ts | 1 + src/app/app.effects.ts | 1 + src/app/app.reducer.ts | 59 ++--- src/app/core/auth/auth-object-factory.ts | 19 ++ src/app/core/auth/auth-request.service.ts | 58 +++++ .../auth/auth-response-parsing.service.ts | 47 ++++ src/app/core/auth/auth-type.ts | 3 + src/app/core/auth/auth.actions.ts | 219 ++++++++++++++++++ src/app/core/auth/auth.effects.ts | 97 ++++++++ src/app/core/auth/auth.reducers.ts | 181 +++++++++++++++ src/app/core/auth/auth.service.ts | 31 ++- src/app/core/auth/authenticated.guard.ts | 8 +- .../core/auth/authentication-status.model.ts | 9 - src/app/core/auth/models/auth-info.model.ts | 5 + src/app/core/auth/models/auth-status.model.ts | 9 + src/app/core/auth/selectors.ts | 84 +++++++ src/app/core/cache/response-cache.models.ts | 10 + src/app/core/core.effects.ts | 2 + src/app/core/core.module.ts | 6 + src/app/core/core.reducers.ts | 7 +- src/app/core/data/request.effects.ts | 4 +- src/app/core/data/request.models.ts | 49 +++- .../dspace-rest-v2/dspace-rest-v2.service.ts | 34 ++- src/app/core/shared/hal-endpoint.service.ts | 5 +- src/app/header/header.component.html | 14 +- src/app/header/header.component.ts | 12 +- .../auth-nav-menu.component.html | 26 +++ .../auth-nav-menu.component.scss | 0 .../auth-nav-menu/auth-nav-menu.component.ts | 61 +++++ src/app/shared/log-in/log-in.component.html | 29 +++ .../log-in.component.scss} | 0 .../shared/log-in/log-in.component.spec.ts | 116 ++++++++++ src/app/shared/log-in/log-in.component.ts | 163 +++++++++++++ src/app/shared/log-out/log-out.component.html | 9 + src/app/shared/log-out/log-out.component.scss | 1 + .../shared/log-out/log-out.component.spec.ts | 45 ++++ src/app/shared/log-out/log-out.component.ts | 89 +++++++ .../login-form/login-form.component.html | 11 - .../shared/login-form/login-form.component.ts | 10 - src/app/shared/shared.module.ts | 8 +- 49 files changed, 1532 insertions(+), 110 deletions(-) create mode 100644 src/app/+logout-page/logout-page-routing.module.ts create mode 100644 src/app/+logout-page/logout-page.component.html create mode 100644 src/app/+logout-page/logout-page.component.scss create mode 100644 src/app/+logout-page/logout-page.component.ts create mode 100644 src/app/+logout-page/logout-page.module.ts create mode 100644 src/app/core/auth/auth-object-factory.ts create mode 100644 src/app/core/auth/auth-request.service.ts create mode 100644 src/app/core/auth/auth-response-parsing.service.ts create mode 100644 src/app/core/auth/auth-type.ts create mode 100644 src/app/core/auth/auth.actions.ts create mode 100644 src/app/core/auth/auth.effects.ts create mode 100644 src/app/core/auth/auth.reducers.ts delete mode 100644 src/app/core/auth/authentication-status.model.ts create mode 100644 src/app/core/auth/models/auth-info.model.ts create mode 100644 src/app/core/auth/models/auth-status.model.ts create mode 100644 src/app/core/auth/selectors.ts create mode 100644 src/app/shared/auth-nav-menu/auth-nav-menu.component.html create mode 100644 src/app/shared/auth-nav-menu/auth-nav-menu.component.scss create mode 100644 src/app/shared/auth-nav-menu/auth-nav-menu.component.ts create mode 100644 src/app/shared/log-in/log-in.component.html rename src/app/shared/{login-form/login-form.component.scss => log-in/log-in.component.scss} (100%) create mode 100644 src/app/shared/log-in/log-in.component.spec.ts create mode 100644 src/app/shared/log-in/log-in.component.ts create mode 100644 src/app/shared/log-out/log-out.component.html create mode 100644 src/app/shared/log-out/log-out.component.scss create mode 100644 src/app/shared/log-out/log-out.component.spec.ts create mode 100644 src/app/shared/log-out/log-out.component.ts delete mode 100644 src/app/shared/login-form/login-form.component.html delete mode 100644 src/app/shared/login-form/login-form.component.ts diff --git a/resources/i18n/en.json b/resources/i18n/en.json index f93c0e933e..c148b06565 100644 --- a/resources/i18n/en.json +++ b/resources/i18n/en.json @@ -47,7 +47,8 @@ }, "nav": { "home": "Home", - "login": "Log In" + "login": "Log In", + "logout": "Log Out" }, "pagination": { "results-per-page": "Results Per Page", @@ -150,10 +151,19 @@ "login": { "title": "Login", "form": { - "header": "Please log in", + "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" + } } } diff --git a/src/app/+home-page/home-page.component.html b/src/app/+home-page/home-page.component.html index 6a3e20ca9d..974ca86f44 100644 --- a/src/app/+home-page/home-page.component.html +++ b/src/app/+home-page/home-page.component.html @@ -1,5 +1,6 @@
+

Loggato

diff --git a/src/app/+home-page/home-page.component.ts b/src/app/+home-page/home-page.component.ts index ad25ec0155..7736d874d9 100644 --- a/src/app/+home-page/home-page.component.ts +++ b/src/app/+home-page/home-page.component.ts @@ -1,10 +1,21 @@ -import { Component } from '@angular/core'; +import { Component, OnInit } from '@angular/core'; +import { isAuthenticated } from '../core/auth/selectors'; +import { Observable } from 'rxjs/Observable'; +import { AppState } from '../app.reducer'; +import { Store } from '@ngrx/store'; @Component({ selector: 'ds-home-page', styleUrls: ['./home-page.component.scss'], templateUrl: './home-page.component.html' }) -export class HomePageComponent { +export class HomePageComponent implements OnInit { + public isAuthenticated: Observable; + + constructor(private store: Store) {} + ngOnInit() { + // set loading + this.isAuthenticated = this.store.select(isAuthenticated); + } } diff --git a/src/app/+login-page/login-page.component.html b/src/app/+login-page/login-page.component.html index 5a37df9d2a..76f4e68ef1 100644 --- a/src/app/+login-page/login-page.component.html +++ b/src/app/+login-page/login-page.component.html @@ -1,5 +1,5 @@

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

- +
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..e427d78ff7 --- /dev/null +++ b/src/app/+logout-page/logout-page.component.html @@ -0,0 +1,5 @@ +
+ +

{{"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..b71eee7ee1 --- /dev/null +++ b/src/app/+logout-page/logout-page.component.scss @@ -0,0 +1,15 @@ +.login-container { + height: 100%; + display: -ms-flexbox; + display: -webkit-box; + display: flex; + -ms-flex-align: center; + -ms-flex-pack: center; + -webkit-box-align: center; + align-items: center; + -webkit-box-pack: center; + justify-content: center; + padding-top: 40px; + padding-bottom: 40px; + background-color: #f5f5f5; +} 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/app-routing.module.ts b/src/app/app-routing.module.ts index c11881800e..72a26fd678 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -13,6 +13,7 @@ import { PageNotFoundComponent } from './pagenotfound/pagenotfound.component'; { path: 'items', loadChildren: './+item-page/item-page.module#ItemPageModule' }, { path: 'search', loadChildren: './+search-page/search-page.module#SearchPageModule' }, { 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.effects.ts b/src/app/app.effects.ts index 7fc42da80d..5cd9c79e30 100644 --- a/src/app/app.effects.ts +++ b/src/app/app.effects.ts @@ -1,6 +1,7 @@ import { HeaderEffects } from './header/header.effects'; import { StoreEffects } from './store.effects'; +import { AuthEffects } from './core/auth/auth.effects'; export const appEffects = [ StoreEffects, diff --git a/src/app/app.reducer.ts b/src/app/app.reducer.ts index 72b29519de..e69f29ae52 100644 --- a/src/app/app.reducer.ts +++ b/src/app/app.reducer.ts @@ -1,29 +1,30 @@ -import { ActionReducerMap } from '@ngrx/store'; -import * as fromRouter from '@ngrx/router-store'; - -import { headerReducer, HeaderState } from './header/header.reducer'; -import { hostWindowReducer, HostWindowState } from './shared/host-window.reducer'; -import { - SearchSidebarState, - sidebarReducer -} from './+search-page/search-sidebar/search-sidebar.reducer'; -import { - filterReducer, - SearchFiltersState -} from './+search-page/search-filters/search-filter/search-filter.reducer'; - -export interface AppState { - router: fromRouter.RouterReducerState; - hostWindow: HostWindowState; - header: HeaderState; - searchSidebar: SearchSidebarState; - searchFilter: SearchFiltersState; -} - -export const appReducers: ActionReducerMap = { - router: fromRouter.routerReducer, - hostWindow: hostWindowReducer, - header: headerReducer, - searchSidebar: sidebarReducer, - searchFilter: filterReducer -}; +import { ActionReducerMap } from '@ngrx/store'; +import * as fromRouter from '@ngrx/router-store'; + +import { headerReducer, HeaderState } from './header/header.reducer'; +import { hostWindowReducer, HostWindowState } from './shared/host-window.reducer'; +import { + SearchSidebarState, + sidebarReducer +} from './+search-page/search-sidebar/search-sidebar.reducer'; +import { + filterReducer, + SearchFiltersState +} from './+search-page/search-filters/search-filter/search-filter.reducer'; +import { authReducer, AuthState } from './core/auth/auth.reducers'; + +export interface AppState { + router: fromRouter.RouterReducerState; + hostWindow: HostWindowState; + header: HeaderState; + searchSidebar: SearchSidebarState; + searchFilter: SearchFiltersState; +} + +export const appReducers: ActionReducerMap = { + router: fromRouter.routerReducer, + hostWindow: hostWindowReducer, + header: headerReducer, + searchSidebar: sidebarReducer, + searchFilter: filterReducer +}; 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..e1960e8111 --- /dev/null +++ b/src/app/core/auth/auth-object-factory.ts @@ -0,0 +1,19 @@ + +import { AuthType } from './auth-type'; +import { AuthStatus } from './models/auth-status.model'; +import { GenericConstructor } from '../shared/generic-constructor'; +import { DSpaceObject } from '../shared/dspace-object.model'; + +export class AuthObjectFactory { + public static getConstructor(type): GenericConstructor { + switch (type) { + case AuthType.Status: { + return AuthStatus + } + + 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..24bdc16135 --- /dev/null +++ b/src/app/core/auth/auth-request.service.ts @@ -0,0 +1,58 @@ +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 { AuthPostRequest, PostRequest, RestRequest } from '../data/request.models'; +import { ResponseCacheEntry } from '../cache/response-cache.reducer'; +import { AuthSuccessResponse, ErrorResponse, RestResponse } from '../cache/response-cache.models'; +import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service'; + +@Injectable() +export class AuthRequestService extends HALEndpointService { + protected linkName = 'authn'; + protected browseEndpoint = ''; + + /** + * True if authenticated + * @type + */ + private _authenticated = false; + + constructor(protected responseCache: ResponseCacheService, + protected requestService: RequestService, + @Inject(GLOBAL_CONFIG) protected EnvConfig: GlobalConfig) { + super(); + } + + protected submitRequest(request: RestRequest): Observable { + const [successResponse, errorResponse] = this.responseCache.get(request.href) + .map((entry: ResponseCacheEntry) => entry.response) + .partition((response: RestResponse) => response.isSuccessful); + return Observable.merge( + errorResponse.flatMap((response: ErrorResponse) => + Observable.throw(new Error(`Couldn't send data to server`))), + successResponse + .filter((response: AuthSuccessResponse) => isNotEmpty(response)) + .map((response: AuthSuccessResponse) => response.authResponse) + .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.getEndpoint() + .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)) + .flatMap((request: PostRequest) => this.submitRequest(request)) + .distinctUntilChanged(); + } +} 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..21a4ae94eb --- /dev/null +++ b/src/app/core/auth/auth-response-parsing.service.ts @@ -0,0 +1,47 @@ +import { Inject, Injectable } from '@angular/core'; + +import { AuthObjectFactory } from './auth-object-factory'; +import { BaseResponseParsingService } from '../data/base-response-parsing.service'; +import { + AuthSuccessResponse, ConfigSuccessResponse, ErrorResponse, + RestResponse +} from '../cache/response-cache.models'; +import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; +import { ConfigObject } from '../shared/config/config.model'; +import { ConfigType } from '../shared/config/config-type'; +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'; + +@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') { + const configDefinition = this.process(data.payload, request.href); + return new ConfigSuccessResponse(configDefinition[Object.keys(configDefinition)[0]], data.statusCode, this.processPageInfo(data.payload.page)); + } else { + return new ErrorResponse( + Object.assign( + new Error('Unexpected response from config endpoint'), + {statusText: data.statusCode} + ) + ); + }*/ + console.log(data); + return new AuthSuccessResponse(data.payload, 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..793c9869a0 --- /dev/null +++ b/src/app/core/auth/auth-type.ts @@ -0,0 +1,3 @@ +export enum AuthType { + 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..694d59b9ee --- /dev/null +++ b/src/app/core/auth/auth.actions.ts @@ -0,0 +1,219 @@ +// 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'; + +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'), + RESET_ERROR: type('dspace/auth/RESET_ERROR'), + 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') +}; + +/* 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: string; + + constructor(token: string) { + this.payload = token; + } +} + +/** + * Authenticated check success. + * @class AuthenticatedSuccessAction + * @implements {Action} + */ +export class AuthenticatedSuccessAction implements Action { + public type: string = AuthActionTypes.AUTHENTICATED_SUCCESS; + payload: { + authenticated: boolean; + user: Eperson + }; + + constructor(authenticated: boolean, user: Eperson) { + this.payload = { authenticated, 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: Eperson; + + constructor(user: Eperson) { + this.payload = user; + } +} + +/** + * Reset error. + * @class ResetAuthenticationErrorAction + * @implements {Action} + */ +export class ResetAuthenticationErrorAction implements Action { + public type: string = AuthActionTypes.RESET_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) {} +} + +/** + * 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; + } +} + +/* tslint:enable:max-classes-per-file */ + +/** + * Actions type. + * @type {AuthActions} + */ +export type AuthActions + = + AuthenticateAction + | AuthenticatedAction + | AuthenticatedErrorAction + | AuthenticatedSuccessAction + | AuthenticationErrorAction + | AuthenticationSuccessAction + | RegistrationAction + | RegistrationErrorAction + | RegistrationSuccessAction; diff --git a/src/app/core/auth/auth.effects.ts b/src/app/core/auth/auth.effects.ts new file mode 100644 index 0000000000..75ca333b5a --- /dev/null +++ b/src/app/core/auth/auth.effects.ts @@ -0,0 +1,97 @@ +import { Injectable } from '@angular/core'; + +// import @ngrx +import { Effect, Actions } from '@ngrx/effects'; +import { Action } 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, LogOutAction, + LogOutErrorAction, + LogOutSuccessAction, RegistrationAction, + RegistrationErrorAction, + RegistrationSuccessAction +} from './auth.actions'; +import { Eperson } from '../eperson/models/eperson.model'; + +/** + * Effects offer a way to isolate and easily test side-effects within your + * application. + * The `toPayload` helper function returns just + * the payload of the currently dispatched action, useful in + * instances where the current state is not necessary. + * + * Documentation on `toPayload` can be found here: + * https://github.com/ngrx/effects/blob/master/docs/api.md#topayload + * + * If you are unfamiliar with the operators being used in these examples, please + * check out the sources below: + * + * Official Docs: http://reactivex.io/rxjs/manual/overview.html#categories-of-operators + * RxJS 5 Operators By Example: https://gist.github.com/btroncone/d6cf141d6f2c00dc6b35 + */ + +@Injectable() +export class AuthEffects { + + /** + * Authenticate user. + * @method authenticate + */ + @Effect() + public authenticate: Observable = this.actions$ + .ofType(AuthActionTypes.AUTHENTICATE) + .debounceTime(500) + .switchMap((action: AuthenticateAction) => { + return this.authService.authenticate(action.payload.email, action.payload.password) + .map((user: Eperson) => new AuthenticationSuccessAction(user)) + .catch((error) => Observable.of(new AuthenticationErrorAction(error))); + }); + + @Effect() + public authenticated: Observable = this.actions$ + .ofType(AuthActionTypes.AUTHENTICATED) + .switchMap((action: AuthenticatedAction) => { + return this.authService.authenticatedUser() + .map((user: Eperson) => new AuthenticatedSuccessAction((user !== null), user)) + .catch((error) => Observable.of(new AuthenticatedErrorAction(error))); + }); + + @Effect() + public createUser: Observable = this.actions$ + .ofType(AuthActionTypes.REGISTRATION) + .debounceTime(500) + .switchMap((action: RegistrationAction) => { + return this.authService.create(action.payload) + .map((user: Eperson) => new RegistrationSuccessAction(user)) + .catch((error) => Observable.of(new RegistrationErrorAction(error))); + }); + + @Effect() + public signOut: Observable = this.actions$ + .ofType(AuthActionTypes.LOG_OUT) + .switchMap((action: LogOutAction) => { + return this.authService.signout() + .map((value) => new LogOutSuccessAction()) + .catch((error) => Observable.of(new LogOutErrorAction(error))); + }); + + /** + * @constructor + * @param {Actions} actions$ + * @param {AuthService} authService + */ + constructor(private actions$: Actions, + private authService: AuthService) { + } +} diff --git a/src/app/core/auth/auth.reducers.ts b/src/app/core/auth/auth.reducers.ts new file mode 100644 index 0000000000..293e7b1527 --- /dev/null +++ b/src/app/core/auth/auth.reducers.ts @@ -0,0 +1,181 @@ +// import actions +import { + AuthActions, AuthActionTypes, AuthenticatedSuccessAction, AuthenticationErrorAction, + AuthenticationSuccessAction, LogOutErrorAction +} from './auth.actions'; + +// import models +import { Eperson } from '../eperson/models/eperson.model'; + +/** + * The auth state. + * @interface State + */ +export interface AuthState { + + // boolean if user is authenticated + authenticated: boolean; + + // error message + error?: string; + + // true if we have attempted existing auth session + loaded: boolean; + + // true when loading + loading: boolean; + + // the authenticated user + user?: Eperson; +} + +/** + * The initial state. + */ +const initialState: AuthState = { + authenticated: null, + 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 + }); + + case AuthActionTypes.AUTHENTICATED_ERROR: + return Object.assign({}, state, { + authenticated: false, + error: (action as AuthenticationErrorAction).payload.message, + loaded: true + }); + + case AuthActionTypes.AUTHENTICATED_SUCCESS: + return Object.assign({}, state, { + authenticated: (action as AuthenticatedSuccessAction).payload.authenticated, + loaded: true, + user: (action as AuthenticatedSuccessAction).payload.user + }); + + case AuthActionTypes.AUTHENTICATE_ERROR: + case AuthActionTypes.REGISTRATION_ERROR: + return Object.assign({}, state, { + authenticated: false, + error: (action as AuthenticationErrorAction).payload.message, + loading: false + }); + + case AuthActionTypes.AUTHENTICATE_SUCCESS: + case AuthActionTypes.REGISTRATION_SUCCESS: + const user: Eperson = (action as AuthenticationSuccessAction).payload; + + // verify user is not null + if (user === null) { + return state; + } + + return Object.assign({}, state, { + authenticated: true, + error: undefined, + loading: false, + user: user + }); + + case AuthActionTypes.RESET_ERROR: + return Object.assign({}, state, { + authenticated: null, + loaded: false, + loading: false + }); + + case AuthActionTypes.LOG_OUT_ERROR: + return Object.assign({}, state, { + authenticated: true, + error: (action as LogOutErrorAction).payload.message, + user: undefined + }); + + case AuthActionTypes.LOG_OUT_SUCCESS: + return Object.assign({}, state, { + authenticated: false, + error: undefined, + user: undefined + }); + + case AuthActionTypes.REGISTRATION: + return Object.assign({}, state, { + authenticated: false, + error: undefined, + loading: true + }); + + default: + return state; + } +} + +/** + * Returns true if the user is authenticated. + * @function isAuthenticated + * @param {State} state + * @returns {boolean} + */ +export const isAuthenticated = (state: AuthState) => state.authenticated; + +/** + * Returns true if the authenticated has loaded. + * @function isAuthenticatedLoaded + * @param {State} state + * @returns {boolean} + */ +export const isAuthenticatedLoaded = (state: AuthState) => state.loaded; + +/** + * Return the users state + * @function getAuthenticatedUser + * @param {State} state + * @returns {User} + */ +export const getAuthenticatedUser = (state: AuthState) => state.user; + +/** + * Returns the authentication error. + * @function getAuthenticationError + * @param {State} state + * @returns {Error} + */ +export const getAuthenticationError = (state: AuthState) => state.error; + +/** + * Returns true if request is in progress. + * @function isLoading + * @param {State} state + * @returns {boolean} + */ +export const isLoading = (state: AuthState) => state.loading; + +/** + * Returns the sign out error. + * @function getLogOutError + * @param {State} state + * @returns {Error} + */ +export const getLogOutError = (state: AuthState) => state.error; + +/** + * Returns the sign up error. + * @function getRegistrationError + * @param {State} state + * @returns {Error} + */ +export const getRegistrationError = (state: AuthState) => state.error; diff --git a/src/app/core/auth/auth.service.ts b/src/app/core/auth/auth.service.ts index 2ca53fd160..c59c5d069e 100644 --- a/src/app/core/auth/auth.service.ts +++ b/src/app/core/auth/auth.service.ts @@ -3,6 +3,9 @@ import { Injectable } from '@angular/core'; import { Observable } from 'rxjs/Observable'; import { Eperson } from '../eperson/models/eperson.model'; +import { AuthRequestService } from './auth-request.service'; +import { HttpHeaders } from '@angular/common/http'; +import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service'; export const MOCK_USER = new Eperson(); MOCK_USER.id = '92a59227-ccf7-46da-9776-86c3fc64147f'; @@ -27,6 +30,8 @@ MOCK_USER.metadata = [ } ]; +export const TOKENITEM = 'ds-token'; + /** * The user service. */ @@ -39,18 +44,36 @@ export class AuthService { */ private _authenticated = false; + constructor(private authRequestService: AuthRequestService) {} + /** * Authenticate the user * - * @param {string} email The user's email address + * @param {string} user The user name * @param {string} password The user's password * @returns {Observable} The authenticated user observable. */ - public authenticate(email: string, password: string): Observable { + public authenticate(user: string, password: string): Observable { // Normally you would do an HTTP request to determine to // attempt authenticating the user using the supplied credentials. - - if (email === MOCK_USER.email && password === 'password') { + // const body = `user=${user}&password=${password}`; + // const body = encodeURI('password=test&user=vera.aloe@mailinator.com'); + // const body = [{user}, {password}]; + const formData: FormData = new FormData(); + formData.append('user', user); + formData.append('password', password); + const body = 'password=' + password.toString() + '&user=' + user.toString(); + const options: HttpOptions = Object.create({}); + let headers = new HttpHeaders(); + headers = headers.append('Content-Type', 'application/x-www-form-urlencoded'); + headers = headers.append('Accept', 'application/json'); + options.headers = headers; + options.responseType = 'text'; + this.authRequestService.postToEndpoint('login', body, options) + .subscribe((r) => { + console.log(r); + }) + if (user === 'test' && password === 'password') { this._authenticated = true; return Observable.of(MOCK_USER); } diff --git a/src/app/core/auth/authenticated.guard.ts b/src/app/core/auth/authenticated.guard.ts index 3cbecf1215..38e0b5e24d 100644 --- a/src/app/core/auth/authenticated.guard.ts +++ b/src/app/core/auth/authenticated.guard.ts @@ -5,10 +5,8 @@ import { Observable } from 'rxjs/Observable'; import { Store } from '@ngrx/store'; // reducers -import { - isAuthenticated, - State -} from '../app.reducers'; +import { CoreState } from '../core.reducers'; +import { isAuthenticated } from './selectors'; /** * Prevent unauthorized activating and loading of routes @@ -20,7 +18,7 @@ export class AuthenticatedGuard implements CanActivate, CanLoad { /** * @constructor */ - constructor(private router: Router, private store: Store) {} + constructor(private router: Router, private store: Store) {} /** * True when user is authenticated diff --git a/src/app/core/auth/authentication-status.model.ts b/src/app/core/auth/authentication-status.model.ts deleted file mode 100644 index 9ef027d600..0000000000 --- a/src/app/core/auth/authentication-status.model.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { DSpaceObject } from '../shared/dspace-object.model'; - -export class AuthenticationStatus extends DSpaceObject { - - okay: boolean; - - authenticated: boolean; - -} diff --git a/src/app/core/auth/models/auth-info.model.ts b/src/app/core/auth/models/auth-info.model.ts new file mode 100644 index 0000000000..8995f82235 --- /dev/null +++ b/src/app/core/auth/models/auth-info.model.ts @@ -0,0 +1,5 @@ +export interface AuthInfo { + access_token?: string, + expires?: number, + expires_in?: 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..388405e87a --- /dev/null +++ b/src/app/core/auth/models/auth-status.model.ts @@ -0,0 +1,9 @@ +import { DSpaceObject } from '../../shared/dspace-object.model'; + +export class AuthStatus extends DSpaceObject { + + okay: boolean; + + authenticated: boolean; + +} diff --git a/src/app/core/auth/selectors.ts b/src/app/core/auth/selectors.ts new file mode 100644 index 0000000000..a260f84762 --- /dev/null +++ b/src/app/core/auth/selectors.ts @@ -0,0 +1,84 @@ +import { createSelector } from '@ngrx/store'; + +import { AuthState } from './auth.reducers'; +import { coreSelector, CoreState } from '../core.reducers'; + +/** + * 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 * as auth from './auth.reducers'; +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 the authenticated user + * @function getAuthenticatedUser + * @param {AuthState} state + * @param {any} props + * @return {User} + */ +export const getAuthenticatedUser = createSelector(getAuthState, auth.getAuthenticatedUser); + +/** + * Returns the authentication error. + * @function getAuthenticationError + * @param {AuthState} state + * @param {any} props + * @return {Error} + */ +export const getAuthenticationError = createSelector(getAuthState, auth.getAuthenticationError); + +/** + * Returns true if the user is authenticated + * @function isAuthenticated + * @param {AuthState} state + * @param {any} props + * @return {boolean} + */ +export const isAuthenticated = createSelector(getAuthState, auth.isAuthenticated); + +/** + * Returns true if the user is authenticated + * @function isAuthenticated + * @param {AuthState} state + * @param {any} props + * @return {boolean} + */ +export const isAuthenticatedLoaded = createSelector(getAuthState, auth.isAuthenticatedLoaded); + +/** + * Returns true if the authentication request is loading. + * @function isAuthenticationLoading + * @param {AuthState} state + * @param {any} props + * @return {boolean} + */ +export const isAuthenticationLoading = createSelector(getAuthState, auth.isLoading); + +/** + * Returns the log out error. + * @function getLogOutError + * @param {AuthState} state + * @param {any} props + * @return {Error} + */ +export const getLogOutError = createSelector(getAuthState, auth.getLogOutError); + +/** + * Returns the registration error. + * @function getRegistrationError + * @param {AuthState} state + * @param {any} props + * @return {Error} + */ +export const getRegistrationError = createSelector(getAuthState, auth.getRegistrationError); diff --git a/src/app/core/cache/response-cache.models.ts b/src/app/core/cache/response-cache.models.ts index 06fc26aa67..0a61a53df7 100644 --- a/src/app/core/cache/response-cache.models.ts +++ b/src/app/core/cache/response-cache.models.ts @@ -62,4 +62,14 @@ export class ConfigSuccessResponse extends RestResponse { super(true, statusCode); } } + +export class AuthSuccessResponse extends RestResponse { + constructor( + public authResponse: any, + public statusCode: string, + public pageInfo?: PageInfo + ) { + super(true, statusCode); + } +} /* tslint:enable:max-classes-per-file */ diff --git a/src/app/core/core.effects.ts b/src/app/core/core.effects.ts index 7cda10b4ae..279b80159f 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, + AuthEffects, ]; diff --git a/src/app/core/core.module.ts b/src/app/core/core.module.ts index 768f05f24b..7ea4ef45d4 100644 --- a/src/app/core/core.module.ts +++ b/src/app/core/core.module.ts @@ -38,6 +38,9 @@ import { SubmissionDefinitionsConfigService } from './config/submission-definiti import { SubmissionFormsConfigService } from './config/submission-forms-config.service'; import { SubmissionSectionsConfigService } from './config/submission-sections-config.service'; import { UUIDService } from './shared/uuid.service'; +import { AuthService } from './auth/auth.service'; +import { AuthenticatedGuard } from './auth/authenticated.guard'; +import { AuthRequestService } from './auth/auth-request.service'; const IMPORTS = [ CommonModule, @@ -55,6 +58,9 @@ const EXPORTS = [ const PROVIDERS = [ ApiService, + AuthenticatedGuard, + AuthRequestService, + AuthService, CommunityDataService, CollectionDataService, DSOResponseParsingService, diff --git a/src/app/core/core.reducers.ts b/src/app/core/core.reducers.ts index d2898eb3c3..37027465b4 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.reducers'; 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/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 ee37f9c3d4..b7c81eaa8e 100644 --- a/src/app/core/data/request.models.ts +++ b/src/app/core/data/request.models.ts @@ -7,6 +7,9 @@ import { ResponseParsingService } from './parsing.service'; import { RootResponseParsingService } from './root-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'; /* tslint:disable:max-classes-per-file */ @@ -34,7 +37,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 ) { } @@ -47,7 +51,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) } @@ -57,7 +62,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) } @@ -67,7 +73,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) } @@ -77,7 +84,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) } @@ -87,7 +95,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) } @@ -97,7 +106,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) } @@ -107,7 +117,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) } @@ -134,7 +145,7 @@ export class FindAllRequest extends GetRequest { constructor( uuid: string, href: string, - public options?: FindAllOptions, + public body?: FindAllOptions, ) { super(uuid, href); } @@ -171,6 +182,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) { + super(uuid, href); + } + + getResponseParser(): GenericConstructor { + return AuthResponseParsingService; + } +} + export class RequestError extends Error { statusText: 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..1a5287d4b0 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,8 +56,25 @@ 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' }) + request(method: RestRequestMethod, url: string, body?: any, options?: HttpOptions): Observable { + const requestOptions: HttpOptions = {}; + requestOptions.body = body; + requestOptions.observe = 'response'; + if (options && options.headers) { + let headers = new HttpHeaders(); + headers = headers.append('Accept', 'application/json'); + headers = headers.append('Content-Type', 'application/x-www-form-urlencoded'); + // requestOptions.headers = new HttpHeaders().set('Content-Type', 'application/x-www-form-urlencoded'); + requestOptions.headers = headers; + /* const keys = options.headers.getAll(''); + keys.forEach((key) => { + requestOptions.headers.append(key, options.headers.get(key)); + })*/ + } + if (options && options.responseType) { + // requestOptions.responseType = options.responseType; + } + return this.http.request(method, url, requestOptions) .map((res) => ({ payload: res.body, statusCode: res.statusText })) .catch((err) => { console.log('Error: ', err); diff --git a/src/app/core/shared/hal-endpoint.service.ts b/src/app/core/shared/hal-endpoint.service.ts index 84587f1eea..109d4e84b3 100644 --- a/src/app/core/shared/hal-endpoint.service.ts +++ b/src/app/core/shared/hal-endpoint.service.ts @@ -23,9 +23,10 @@ export abstract class HALEndpointService { .distinctUntilChanged(); } - public getEndpoint(): Observable { + public getEndpoint(linkName?: string): Observable { + const mapLinkName = isNotEmpty(linkName) ? linkName : this.linkName; return this.getEndpointMap() - .map((map: EndpointMap) => map[this.linkName]) + .map((map: EndpointMap) => map[mapLinkName]) .distinctUntilChanged(); } diff --git a/src/app/header/header.component.html b/src/app/header/header.component.html index ac76ea1dab..2edfa8a796 100644 --- a/src/app/header/header.component.html +++ b/src/app/header/header.component.html @@ -12,19 +12,7 @@ {{ 'nav.home' | translate }}(current) - + diff --git a/src/app/header/header.component.ts b/src/app/header/header.component.ts index 4be64221dc..93cb329f4f 100644 --- a/src/app/header/header.component.ts +++ b/src/app/header/header.component.ts @@ -1,12 +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'; -import { fadeInOut } from '../shared/animations/fade'; const headerStateSelector = (state: AppState) => state.header; const navCollapsedSelector = createSelector(headerStateSelector, (header: HeaderState) => header.navCollapsed); @@ -15,12 +15,15 @@ const navCollapsedSelector = createSelector(headerStateSelector, (header: Header selector: 'ds-header', styleUrls: ['header.component.scss'], templateUrl: 'header.component.html', - animations: [ - fadeInOut - ] }) export class HeaderComponent implements OnInit { + /** + * Whether user is authenticated. + * @type {Observable} + */ + public isAuthenticated: Observable; public isNavBarCollapsed: Observable; + public showAuth = false; constructor( private store: Store, @@ -29,6 +32,7 @@ export class HeaderComponent implements OnInit { } ngOnInit(): void { + // set loading this.isNavBarCollapsed = this.store.select(navCollapsedSelector); } 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..34db6a2130 --- /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..e69de29bb2 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..e188498b0a --- /dev/null +++ b/src/app/shared/auth-nav-menu/auth-nav-menu.component.ts @@ -0,0 +1,61 @@ + +import { Component, OnDestroy, 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 { CoreState } from '../../core/core.reducers'; +import { HostWindowService } from '../host-window.service'; +import { AppState } from '../../app.reducer'; +import { hasValue, isNotUndefined } from '../empty.util'; +import { getAuthenticatedUser, isAuthenticated } from '../../core/auth/selectors'; +import { Subscription } from 'rxjs/Subscription'; +import { Eperson } from '../../core/eperson/models/eperson.model'; + +const routerStateSelector = (state: AppState) => state.router; + +@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 OnDestroy, OnInit { + /** + * Whether user is authenticated. + * @type {Observable} + */ + public isAuthenticated: Observable; + + public showAuth = false; + + public user: Observable; + + protected subs: Subscription[] = []; + + constructor( + private appStore: Store, + private coreStore: Store, + private windowService: HostWindowService) { + } + + ngOnInit(): void { + // set loading + this.isAuthenticated = this.coreStore.select(isAuthenticated); + + this.user = this.appStore.select(getAuthenticatedUser); + + this.subs.push(this.appStore.select(routerStateSelector) + .filter((router: RouterReducerState) => isNotUndefined(router)) + .subscribe((router: RouterReducerState) => { + this.showAuth = router.state.url !== '/login'; + })); + } + + ngOnDestroy() { + this.subs + .filter((sub) => hasValue(sub)) + .forEach((sub) => sub.unsubscribe()); + } +} 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..92b7cb08bb --- /dev/null +++ b/src/app/shared/log-in/log-in.component.html @@ -0,0 +1,29 @@ + + + + diff --git a/src/app/shared/login-form/login-form.component.scss b/src/app/shared/log-in/log-in.component.scss similarity index 100% rename from src/app/shared/login-form/login-form.component.scss rename to src/app/shared/log-in/log-in.component.scss 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..76fd631c5b --- /dev/null +++ b/src/app/shared/log-in/log-in.component.spec.ts @@ -0,0 +1,116 @@ +/* tslint:disable:no-unused-variable */ +import { CUSTOM_ELEMENTS_SCHEMA, DebugElement } from "@angular/core"; +import { ComponentFixture, TestBed, async } from "@angular/core/testing"; +import { FormBuilder, FormGroup, FormsModule, ReactiveFormsModule } from "@angular/forms"; +import { MaterialModule } from "@angular/material"; +import { By } from "@angular/platform-browser"; +import { Store, StoreModule } from "@ngrx/store"; +import { go } from "@ngrx/router-store"; + +// reducers +import { reducer } from "../../app.reducers"; + +// models +import { User } from "../../core/models/user"; + +// services +import { MOCK_USER } from "../../core/services/user.service"; + +// this component to test +import { LogInComponent } from "./log-in.component"; + +describe("LogInComponent", () => { + + let component: LogInComponent; + let fixture: ComponentFixture; + let page: Page; + let user: User = new User(); + + beforeEach(() => { + user = MOCK_USER; + }); + + beforeEach(async(() => { + // refine the test module by declaring the test component + TestBed.configureTestingModule({ + imports: [ + FormsModule, + MaterialModule, + ReactiveFormsModule, + StoreModule.provideStore(reducer) + ], + declarations: [ + LogInComponent + ], + schemas: [ + CUSTOM_ELEMENTS_SCHEMA + ] + }) + .compileComponents(); + + // create component and test fixture + fixture = TestBed.createComponent(LogInComponent); + + // get test component from the fixture + component = fixture.componentInstance; + })); + + beforeEach(() => { + // 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.email); + component.form.controls["password"].setValue(user.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\"]"; + // console.log(this.fixture.debugElement.query(By.css(emailInputSelector))); + 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..faf17eedd5 --- /dev/null +++ b/src/app/shared/log-in/log-in.component.ts @@ -0,0 +1,163 @@ +import { Component, OnDestroy, OnInit } from '@angular/core'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; + +// @ngrx +import { Store } from '@ngrx/store'; + +// rxjs +import { Observable } from 'rxjs/Observable'; +import 'rxjs/add/operator/filter'; +import 'rxjs/add/operator/takeWhile'; + +// actions +import { AuthenticateAction, ResetAuthenticationErrorAction } from '../../core/auth/auth.actions'; + +// reducers +import { + getAuthenticationError, + isAuthenticated, + isAuthenticationLoading, +} from '../../core/auth/selectors'; +import { Router } from '@angular/router'; +import { CoreState } from '../../core/core.reducers'; + +import { isNotEmpty, isNotNull } from '../empty.util'; +import { fadeOut } from '../animations/fade'; + +/** + * /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; + + /** + * 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 {FormBuilder} formBuilder + * @param {Store} store + */ + constructor( + private formBuilder: FormBuilder, + private router: Router, + private store: Store + ) { } + + /** + * Lifecycle hook that is called after data-bound properties of a directive are initialized. + * @method ngOnInit + */ + public ngOnInit() { + // 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 loading + this.loading = this.store.select(isAuthenticationLoading); + + // subscribe to success + this.store.select(isAuthenticated) + .takeWhile(() => this.alive) + .filter((authenticated) => authenticated) + .subscribe(() => { + this.router.navigate(['/']); + }); + } + + /** + * Lifecycle hook that is called when a directive, pipe or service is destroyed. + * @method ngOnDestroy + */ + public ngOnDestroy() { + this.alive = false; + } + + /** + * Go to the home page. + * @method home + */ + public home() { + this.router.navigate(['/home']); + } + + /** + * Reset error. + */ + public resetError() { + if (this.hasError) { + this.store.dispatch(new ResetAuthenticationErrorAction()); + this.hasError = false; + } + } + + /** + * To to the registration page. + * @method register + */ + public register() { + this.router.navigate(['/register']); + } + + /** + * Submit the authentication form. + * @method submit + */ + public submit() { + // 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..6e4ee32f6f --- /dev/null +++ b/src/app/shared/log-out/log-out.component.html @@ -0,0 +1,9 @@ + + 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..4beb53aadc --- /dev/null +++ b/src/app/shared/log-out/log-out.component.spec.ts @@ -0,0 +1,45 @@ +/* tslint:disable:no-unused-variable */ +import { DebugElement, CUSTOM_ELEMENTS_SCHEMA } from "@angular/core"; +import { async, ComponentFixture, TestBed } from "@angular/core/testing"; +import { By } from "@angular/platform-browser"; +import { Router } from "@angular/router"; + +// import ngrx +import { Store, StoreModule } from "@ngrx/store"; + +// reducers +import { reducer } from "../../app.reducers"; + +// test this component +import { SignOutComponent } from "./log-out.component"; + +describe("Component: Signout", () => { + let component: SignOutComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + // refine the test module by declaring the test component + TestBed.configureTestingModule({ + imports: [ + StoreModule.provideStore(reducer) + ], + declarations: [ + SignOutComponent + ], + schemas: [ + CUSTOM_ELEMENTS_SCHEMA + ] + }) + .compileComponents(); + + // create component and test fixture + fixture = TestBed.createComponent(SignOutComponent); + + // get test component from the fixture + component = fixture.componentInstance; + })); + + it("should create an instance", () => { + expect(component).toBeTruthy(); + }); +}); 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..36c5914347 --- /dev/null +++ b/src/app/shared/log-out/log-out.component.ts @@ -0,0 +1,89 @@ +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']); + } + + /** + * To to the log in page. + */ + public logIn() { + this.router.navigate(['/login']); + } + + public logOut() { + this.store.dispatch(new LogOutAction()); + } + +} diff --git a/src/app/shared/login-form/login-form.component.html b/src/app/shared/login-form/login-form.component.html deleted file mode 100644 index 6dcc4d1a5c..0000000000 --- a/src/app/shared/login-form/login-form.component.html +++ /dev/null @@ -1,11 +0,0 @@ - - diff --git a/src/app/shared/login-form/login-form.component.ts b/src/app/shared/login-form/login-form.component.ts deleted file mode 100644 index 39fb3a8936..0000000000 --- a/src/app/shared/login-form/login-form.component.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Component } from '@angular/core'; - -@Component({ - selector: 'ds-login-form', - styleUrls: ['./login-form.component.scss'], - templateUrl: './login-form.component.html' -}) -export class LoginFormComponent { - -} diff --git a/src/app/shared/shared.module.ts b/src/app/shared/shared.module.ts index 2f5a266d9f..0eb2696614 100644 --- a/src/app/shared/shared.module.ts +++ b/src/app/shared/shared.module.ts @@ -41,7 +41,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 { LoginFormComponent } from './login-form/login-form.component'; +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'; const MODULES = [ // Do NOT include UniversalModule, HttpModule, or JsonpModule here @@ -64,12 +66,14 @@ const PIPES = [ const COMPONENTS = [ // put shared components here + AuthNavMenuComponent, ComcolPageContentComponent, ComcolPageHeaderComponent, ComcolPageLogoComponent, ErrorComponent, LoadingComponent, - LoginFormComponent, + LogInComponent, + LogOutComponent, ObjectListComponent, AbstractListableElementComponent, WrapperListElementComponent, From ae584915cf91b9b0271897a4985807cfae6de2da Mon Sep 17 00:00:00 2001 From: Giuseppe Digilio Date: Tue, 6 Feb 2018 15:55:04 +0100 Subject: [PATCH 05/80] Fix error with AOT compilation --- src/app/shared/auth-nav-menu/auth-nav-menu.component.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index e188498b0a..453f68ea86 100644 --- a/src/app/shared/auth-nav-menu/auth-nav-menu.component.ts +++ b/src/app/shared/auth-nav-menu/auth-nav-menu.component.ts @@ -37,7 +37,7 @@ export class AuthNavMenuComponent implements OnDestroy, OnInit { constructor( private appStore: Store, private coreStore: Store, - private windowService: HostWindowService) { + public windowService: HostWindowService) { } ngOnInit(): void { From 2f19f32d91b118abf0083d358960b5c0245d5491 Mon Sep 17 00:00:00 2001 From: Giuseppe Digilio Date: Fri, 9 Feb 2018 09:55:55 +0100 Subject: [PATCH 06/80] Improvement for authentication module --- src/app/+home-page/home-page.component.ts | 4 +- src/app/core/auth/auth-object-factory.ts | 14 ++- src/app/core/auth/auth-request.service.ts | 23 +++- .../auth/auth-response-parsing.service.ts | 28 ++--- src/app/core/auth/auth-storage.service.ts | 34 ++++++ src/app/core/auth/auth-type.ts | 1 + src/app/core/auth/auth.actions.ts | 11 +- src/app/core/auth/auth.effects.ts | 37 +++--- src/app/core/auth/auth.interceptor.ts | 97 ++++++++++++++++ src/app/core/auth/auth.reducers.ts | 31 ++++-- src/app/core/auth/auth.service.ts | 105 ++++++++++++++---- src/app/core/auth/authenticated.guard.ts | 32 ++++-- src/app/core/auth/models/auth-error.model.ts | 7 ++ src/app/core/auth/models/auth-info.model.ts | 5 - src/app/core/auth/models/auth-status.model.ts | 8 ++ .../core/auth/models/auth-token-info.model.ts | 11 ++ .../models/normalized-auth-status.model.ts | 26 +++++ src/app/core/cache/response-cache.models.ts | 32 +++++- src/app/core/core.module.ts | 14 ++- src/app/core/data/request.models.ts | 4 +- src/app/core/data/request.service.ts | 11 +- .../dspace-rest-v2-response.model.ts | 3 + .../dspace-rest-v2/dspace-rest-v2.service.ts | 17 +-- 23 files changed, 431 insertions(+), 124 deletions(-) create mode 100644 src/app/core/auth/auth-storage.service.ts create mode 100644 src/app/core/auth/auth.interceptor.ts create mode 100644 src/app/core/auth/models/auth-error.model.ts delete mode 100644 src/app/core/auth/models/auth-info.model.ts create mode 100644 src/app/core/auth/models/auth-token-info.model.ts create mode 100644 src/app/core/auth/models/normalized-auth-status.model.ts diff --git a/src/app/+home-page/home-page.component.ts b/src/app/+home-page/home-page.component.ts index 7736d874d9..edaa032d42 100644 --- a/src/app/+home-page/home-page.component.ts +++ b/src/app/+home-page/home-page.component.ts @@ -12,7 +12,9 @@ import { Store } from '@ngrx/store'; export class HomePageComponent implements OnInit { public isAuthenticated: Observable; - constructor(private store: Store) {} + constructor(private store: Store) { + } + ngOnInit() { // set loading this.isAuthenticated = this.store.select(isAuthenticated); diff --git a/src/app/core/auth/auth-object-factory.ts b/src/app/core/auth/auth-object-factory.ts index e1960e8111..c3e70eaaac 100644 --- a/src/app/core/auth/auth-object-factory.ts +++ b/src/app/core/auth/auth-object-factory.ts @@ -1,14 +1,18 @@ - import { AuthType } from './auth-type'; -import { AuthStatus } from './models/auth-status.model'; import { GenericConstructor } from '../shared/generic-constructor'; -import { DSpaceObject } from '../shared/dspace-object.model'; +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 { + public static getConstructor(type): GenericConstructor { switch (type) { + case AuthType.Eperson: { + return NormalizedEpersonModel + } + case AuthType.Status: { - return AuthStatus + return NormalizedAuthStatus } default: { diff --git a/src/app/core/auth/auth-request.service.ts b/src/app/core/auth/auth-request.service.ts index 24bdc16135..6202b6784d 100644 --- a/src/app/core/auth/auth-request.service.ts +++ b/src/app/core/auth/auth-request.service.ts @@ -6,7 +6,7 @@ import { GLOBAL_CONFIG } from '../../../config'; import { GlobalConfig } from '../../../config/global-config.interface'; import { Observable } from 'rxjs/Observable'; import { isNotEmpty } from '../../shared/empty.util'; -import { AuthPostRequest, PostRequest, RestRequest } from '../data/request.models'; +import { AuthGetRequest, AuthPostRequest, PostRequest, RestRequest } from '../data/request.models'; import { ResponseCacheEntry } from '../cache/response-cache.reducer'; import { AuthSuccessResponse, ErrorResponse, RestResponse } from '../cache/response-cache.models'; import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service'; @@ -28,16 +28,16 @@ export class AuthRequestService extends HALEndpointService { super(); } - protected submitRequest(request: RestRequest): Observable { + protected fetchRequest(request: RestRequest): Observable { const [successResponse, errorResponse] = this.responseCache.get(request.href) .map((entry: ResponseCacheEntry) => entry.response) .partition((response: RestResponse) => response.isSuccessful); return Observable.merge( errorResponse.flatMap((response: ErrorResponse) => - Observable.throw(new Error(`Couldn't send data to server`))), + Observable.throw(new Error(response.errorMessage))), successResponse .filter((response: AuthSuccessResponse) => isNotEmpty(response)) - .map((response: AuthSuccessResponse) => response.authResponse) + .map((response: AuthSuccessResponse) => response.response) .distinctUntilChanged()); } @@ -51,8 +51,19 @@ export class AuthRequestService extends HALEndpointService { .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)) - .flatMap((request: PostRequest) => this.submitRequest(request)) + .do((request: PostRequest) => this.requestService.configure(request, true)) + .flatMap((request: PostRequest) => this.fetchRequest(request)) + .distinctUntilChanged(); + } + + public getRequest(method: string, options?: HttpOptions): Observable { + return this.getEndpoint() + .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.ts b/src/app/core/auth/auth-response-parsing.service.ts index 21a4ae94eb..1075cfe059 100644 --- a/src/app/core/auth/auth-response-parsing.service.ts +++ b/src/app/core/auth/auth-response-parsing.service.ts @@ -3,6 +3,8 @@ import { Inject, Injectable } from '@angular/core'; import { AuthObjectFactory } from './auth-object-factory'; import { BaseResponseParsingService } from '../data/base-response-parsing.service'; import { + AuthErrorResponse, + AuthStatusResponse, AuthSuccessResponse, ConfigSuccessResponse, ErrorResponse, RestResponse } from '../cache/response-cache.models'; @@ -11,10 +13,15 @@ import { ConfigObject } from '../shared/config/config.model'; import { ConfigType } from '../shared/config/config-type'; import { GLOBAL_CONFIG } from '../../../config'; import { GlobalConfig } from '../../../config/global-config.interface'; -import { isNotEmpty } from '../../shared/empty.util'; +import { isEmpty, 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 { NormalizedObject } from '../cache/models/normalized-object.model'; +import { AuthTokenInfo } from './models/auth-token-info.model'; +import { NormalizedAuthStatus } from './models/normalized-auth-status.model'; +import { AuthStatus } from './models/auth-status.model'; @Injectable() export class AuthResponseParsingService extends BaseResponseParsingService implements ResponseParsingService { @@ -29,19 +36,14 @@ export class AuthResponseParsingService extends BaseResponseParsingService imple } parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse { - /*if (isNotEmpty(data.payload) && isNotEmpty(data.payload._links) && data.statusCode === '200') { - const configDefinition = this.process(data.payload, request.href); - return new ConfigSuccessResponse(configDefinition[Object.keys(configDefinition)[0]], data.statusCode, this.processPageInfo(data.payload.page)); + if (isNotEmpty(data.payload) && isNotEmpty(data.payload._links) && data.statusCode === '200') { + const response = this.process(data.payload, request.href); + return new AuthStatusResponse(response[Object.keys(response)[0]][0], data.statusCode); + } else if (isEmpty(data.payload) && isNotEmpty(data.headers.get('authorization')) && data.statusCode === '200') { + return new AuthSuccessResponse(new AuthTokenInfo(data.headers.get('authorization')), data.statusCode); } else { - return new ErrorResponse( - Object.assign( - new Error('Unexpected response from config endpoint'), - {statusText: data.statusCode} - ) - ); - }*/ - console.log(data); - return new AuthSuccessResponse(data.payload, data.statusCode) + return new AuthStatusResponse(data.payload as AuthStatus, data.statusCode); + } } } diff --git a/src/app/core/auth/auth-storage.service.ts b/src/app/core/auth/auth-storage.service.ts new file mode 100644 index 0000000000..01e41d850b --- /dev/null +++ b/src/app/core/auth/auth-storage.service.ts @@ -0,0 +1,34 @@ +import { Inject, Injectable, PLATFORM_ID } from '@angular/core'; +import { isPlatformBrowser } from '@angular/common'; + +/** + * The auth service. + */ +@Injectable() +export class AuthStorageService { + + constructor(@Inject(PLATFORM_ID) private platformId: string) {} + + public get(key: string): any { + let item = null; + if (isPlatformBrowser(this.platformId)) { + item = JSON.parse(localStorage.getItem(key)); + } + return item; + } + + public store(key: string, item: any) { + if (isPlatformBrowser(this.platformId)) { + localStorage.setItem(key, JSON.stringify(item)); + } + return true; + } + + public remove(key: string) { + if (isPlatformBrowser(this.platformId)) { + localStorage.removeItem(key); + } + return true; + } + +} diff --git a/src/app/core/auth/auth-type.ts b/src/app/core/auth/auth-type.ts index 793c9869a0..b8879ae445 100644 --- a/src/app/core/auth/auth-type.ts +++ b/src/app/core/auth/auth-type.ts @@ -1,3 +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 index 694d59b9ee..cf8ce68bd6 100644 --- a/src/app/core/auth/auth.actions.ts +++ b/src/app/core/auth/auth.actions.ts @@ -6,6 +6,7 @@ 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'), @@ -49,9 +50,9 @@ export class AuthenticateAction implements Action { */ export class AuthenticatedAction implements Action { public type: string = AuthActionTypes.AUTHENTICATED; - payload: string; + payload: AuthTokenInfo; - constructor(token: string) { + constructor(token: AuthTokenInfo) { this.payload = token; } } @@ -108,10 +109,10 @@ export class AuthenticationErrorAction implements Action { */ export class AuthenticationSuccessAction implements Action { public type: string = AuthActionTypes.AUTHENTICATE_SUCCESS; - payload: Eperson; + payload: AuthTokenInfo; - constructor(user: Eperson) { - this.payload = user; + constructor(token: AuthTokenInfo) { + this.payload = token; } } diff --git a/src/app/core/auth/auth.effects.ts b/src/app/core/auth/auth.effects.ts index 75ca333b5a..aa7503fe4c 100644 --- a/src/app/core/auth/auth.effects.ts +++ b/src/app/core/auth/auth.effects.ts @@ -23,23 +23,7 @@ import { RegistrationSuccessAction } from './auth.actions'; import { Eperson } from '../eperson/models/eperson.model'; - -/** - * Effects offer a way to isolate and easily test side-effects within your - * application. - * The `toPayload` helper function returns just - * the payload of the currently dispatched action, useful in - * instances where the current state is not necessary. - * - * Documentation on `toPayload` can be found here: - * https://github.com/ngrx/effects/blob/master/docs/api.md#topayload - * - * If you are unfamiliar with the operators being used in these examples, please - * check out the sources below: - * - * Official Docs: http://reactivex.io/rxjs/manual/overview.html#categories-of-operators - * RxJS 5 Operators By Example: https://gist.github.com/btroncone/d6cf141d6f2c00dc6b35 - */ +import { AuthStatus } from './models/auth-status.model'; @Injectable() export class AuthEffects { @@ -51,18 +35,29 @@ export class AuthEffects { @Effect() public authenticate: Observable = this.actions$ .ofType(AuthActionTypes.AUTHENTICATE) - .debounceTime(500) .switchMap((action: AuthenticateAction) => { return this.authService.authenticate(action.payload.email, action.payload.password) - .map((user: Eperson) => new AuthenticationSuccessAction(user)) + .map((response: AuthStatus) => new AuthenticationSuccessAction(response.token)) .catch((error) => Observable.of(new AuthenticationErrorAction(error))); }); + // It means "reacts to this action but don't send another" + @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({dispatch: false}) + public logOutSuccess: Observable = this.actions$ + .ofType(AuthActionTypes.LOG_OUT_SUCCESS) + .do((action: LogOutSuccessAction) => this.authService.removeToken()); + @Effect() public authenticated: Observable = this.actions$ .ofType(AuthActionTypes.AUTHENTICATED) .switchMap((action: AuthenticatedAction) => { - return this.authService.authenticatedUser() + return this.authService.authenticatedUser(action.payload) .map((user: Eperson) => new AuthenticatedSuccessAction((user !== null), user)) .catch((error) => Observable.of(new AuthenticatedErrorAction(error))); }); @@ -70,7 +65,7 @@ export class AuthEffects { @Effect() public createUser: Observable = this.actions$ .ofType(AuthActionTypes.REGISTRATION) - .debounceTime(500) + .debounceTime(500) // to remove when functionality is implemented .switchMap((action: RegistrationAction) => { return this.authService.create(action.payload) .map((user: Eperson) => new RegistrationSuccessAction(user)) diff --git a/src/app/core/auth/auth.interceptor.ts b/src/app/core/auth/auth.interceptor.ts new file mode 100644 index 0000000000..40fd0d3836 --- /dev/null +++ b/src/app/core/auth/auth.interceptor.ts @@ -0,0 +1,97 @@ +import { Injectable, Injector } from '@angular/core'; +import { Router } from '@angular/router'; +import { + HttpClient, HttpEvent, HttpInterceptor, HttpHandler, HttpRequest, HttpResponse, + HttpErrorResponse +} from '@angular/common/http'; +import { Observable } from 'rxjs/Rx'; +import 'rxjs/add/observable/throw' +import 'rxjs/add/operator/catch'; + +import { AuthService } from './auth.service'; +import { AuthStatus } from './models/auth-status.model'; +import { AuthType } from './auth-type'; +import { ResourceType } from '../shared/resource-type'; +import { AuthTokenInfo } from './models/auth-token-info.model'; +import { isNotEmpty } from '../../shared/empty.util'; + +@Injectable() +export class AuthInterceptor implements HttpInterceptor { + constructor(private inj: Injector, private router: Router) { } + + private isUnauthorized(status: number): boolean { + return status === 401 || status === 403; + } + + private isLoginResponse(url: string): boolean { + return url.endsWith('/authn/login'); + } + + private isLogoutResponse(url: string): boolean { + return 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 = JSON.parse(error); + } + return authStatus; + } + + intercept(req: HttpRequest, next: HttpHandler): Observable> { + + const authService = this.inj.get(AuthService); + + // Get the auth header from the service. + const Authorization = authService.getAuthHeader(); + + let authReq; + if (isNotEmpty(Authorization)) { + // Clone the request to add the new header. + authReq = req.clone({headers: req.headers.set('authorization', Authorization)}); + } else { + authReq = req.clone(); + } + + // Pass on the cloned request instead of the original request. + return next.handle(authReq) + .map((response) => { + if (response instanceof HttpResponse && response.status === 200 && (this.isLoginResponse(response.url) || this.isLogoutResponse(response.url))) { + let authRes: HttpResponse; + if (this.isLoginResponse(response.url)) { + const token = response.headers.get('authorization'); + authRes = response.clone({body: this.makeAuthStatusObject(true, token)}); + } else { + authRes = response.clone({body: this.makeAuthStatusObject(false)}); + } + return authRes; + } else { + return response; + } + }) + .catch((error, caught) => { + // Intercept an unauthorized error response + if (error instanceof HttpErrorResponse && this.isUnauthorized(error.status)) { + // 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 { + // Return error response as is. + return Observable.throw(error); + } + }) as any; + } +} diff --git a/src/app/core/auth/auth.reducers.ts b/src/app/core/auth/auth.reducers.ts index 293e7b1527..645541aa8e 100644 --- a/src/app/core/auth/auth.reducers.ts +++ b/src/app/core/auth/auth.reducers.ts @@ -6,6 +6,7 @@ import { // import models import { Eperson } from '../eperson/models/eperson.model'; +import { AuthTokenInfo } from './models/auth-token-info.model'; /** * The auth state. @@ -25,6 +26,9 @@ export interface AuthState { // true when loading loading: boolean; + // access token + token?: AuthTokenInfo; + // the authenticated user user?: Eperson; } @@ -62,8 +66,10 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut case AuthActionTypes.AUTHENTICATED_SUCCESS: return Object.assign({}, state, { - authenticated: (action as AuthenticatedSuccessAction).payload.authenticated, + authenticated: true, loaded: true, + error: undefined, + loading: false, user: (action as AuthenticatedSuccessAction).payload.user }); @@ -76,26 +82,26 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut }); case AuthActionTypes.AUTHENTICATE_SUCCESS: - case AuthActionTypes.REGISTRATION_SUCCESS: - const user: Eperson = (action as AuthenticationSuccessAction).payload; + const token: AuthTokenInfo = (action as AuthenticationSuccessAction).payload; - // verify user is not null - if (user === null) { + // verify token is not null + if (token === null) { return state; } return Object.assign({}, state, { - authenticated: true, - error: undefined, - loading: false, - user: user + token: token }); + case AuthActionTypes.REGISTRATION_SUCCESS: + return state; + case AuthActionTypes.RESET_ERROR: return Object.assign({}, state, { authenticated: null, + error: undefined, loaded: false, - loading: false + loading: false, }); case AuthActionTypes.LOG_OUT_ERROR: @@ -109,7 +115,10 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut return Object.assign({}, state, { authenticated: false, error: undefined, - user: undefined + loaded: false, + loading: false, + user: undefined, + token: undefined }); case AuthActionTypes.REGISTRATION: diff --git a/src/app/core/auth/auth.service.ts b/src/app/core/auth/auth.service.ts index c59c5d069e..a5373e1afd 100644 --- a/src/app/core/auth/auth.service.ts +++ b/src/app/core/auth/auth.service.ts @@ -6,6 +6,10 @@ import { Eperson } from '../eperson/models/eperson.model'; import { AuthRequestService } from './auth-request.service'; import { HttpHeaders } from '@angular/common/http'; import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service'; +import { AuthStatus } from './models/auth-status.model'; +import { AuthTokenInfo } from './models/auth-token-info.model'; +import { isNotEmpty, isNotNull } from '../../shared/empty.util'; +import { AuthStorageService } from './auth-storage.service'; export const MOCK_USER = new Eperson(); MOCK_USER.id = '92a59227-ccf7-46da-9776-86c3fc64147f'; @@ -30,21 +34,28 @@ MOCK_USER.metadata = [ } ]; -export const TOKENITEM = 'ds-token'; +export const TOKENITEM = 'dsAuthInfo'; /** - * The user service. + * The auth service. */ @Injectable() export class AuthService { /** * True if authenticated - * @type + * @type boolean */ private _authenticated = false; - constructor(private authRequestService: AuthRequestService) {} + /** + * The url to redirect after login + * @type string + */ + private _redirectUrl: string; + + constructor(private authRequestService: AuthRequestService, private storage: AuthStorageService) { + } /** * Authenticate the user @@ -53,32 +64,28 @@ export class AuthService { * @param {string} password The user's password * @returns {Observable} The authenticated user observable. */ - public authenticate(user: string, password: string): Observable { + public authenticate(user: string, password: string): Observable { // Normally you would do an HTTP request to determine to // attempt authenticating the user using the supplied credentials. // const body = `user=${user}&password=${password}`; // const body = encodeURI('password=test&user=vera.aloe@mailinator.com'); // const body = [{user}, {password}]; - const formData: FormData = new FormData(); - formData.append('user', user); - formData.append('password', password); - const body = 'password=' + password.toString() + '&user=' + user.toString(); + // const body = encodeURI('password=' + password.toString() + '&user=' + user.toString()); + const body = encodeURI(`password=${password}&user=${user}`); const options: HttpOptions = Object.create({}); let headers = new HttpHeaders(); headers = headers.append('Content-Type', 'application/x-www-form-urlencoded'); - headers = headers.append('Accept', 'application/json'); options.headers = headers; options.responseType = 'text'; - this.authRequestService.postToEndpoint('login', body, options) - .subscribe((r) => { - console.log(r); + return this.authRequestService.postToEndpoint('login', body, options) + .map((status: AuthStatus) => { + if (status.authenticated) { + return status; + } else { + throw(new Error('Invalid email or password')); + } }) - if (user === 'test' && password === 'password') { - this._authenticated = true; - return Observable.of(MOCK_USER); - } - return Observable.throw(new Error('Invalid email or password')); } /** @@ -93,11 +100,25 @@ export class AuthService { * Returns the authenticated user * @returns {User} */ - public authenticatedUser(): Observable { + public authenticatedUser(token: AuthTokenInfo): Observable { // Normally you would do an HTTP request to determine if // the user has an existing auth session on the server // but, let's just return the mock user for this example. - return Observable.of(MOCK_USER); + 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) { + this._authenticated = true; + return status.eperson[0]; + } else { + this._authenticated = false; + throw(new Error('Not authenticated')); + } + }); } /** @@ -119,7 +140,47 @@ export class AuthService { public signout(): Observable { // Normally you would do an HTTP request sign end the session // but, let's just return an observable of true. - this._authenticated = false; - return Observable.of(true); + 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) { + this._authenticated = false; + return true; + } else { + throw(new Error('Invalid email or password')); + } + }) + + } + + public getAuthHeader(): string { + // Retrieve authentication token info + const token = this.storage.get(TOKENITEM); + return (isNotNull(token) && this._authenticated) ? `Bearer ${token.accessToken}` : ''; + } + + public getToken(): AuthTokenInfo { + // Retrieve authentication token info + return this.storage.get(TOKENITEM); + } + + public storeToken(token: AuthTokenInfo) { + // Save authentication token info + return this.storage.store(TOKENITEM, JSON.stringify(token)); + } + + public removeToken() { + // Remove authentication token info + return this.storage.remove(TOKENITEM); + } + + get redirectUrl(): string { + return this._redirectUrl; + } + + set redirectUrl(value: string) { + this._redirectUrl = value; } } diff --git a/src/app/core/auth/authenticated.guard.ts b/src/app/core/auth/authenticated.guard.ts index 38e0b5e24d..cd39981a1f 100644 --- a/src/app/core/auth/authenticated.guard.ts +++ b/src/app/core/auth/authenticated.guard.ts @@ -7,6 +7,7 @@ import { Store } from '@ngrx/store'; // reducers import { CoreState } from '../core.reducers'; import { isAuthenticated } from './selectors'; +import { AuthService } from './auth.service'; /** * Prevent unauthorized activating and loading of routes @@ -18,37 +19,44 @@ export class AuthenticatedGuard implements CanActivate, CanLoad { /** * @constructor */ - constructor(private router: Router, private store: Store) {} + constructor(private authService: AuthService, private router: Router, private store: Store) {} /** * True when user is authenticated * @method canActivate */ - canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable | boolean { - // get observable - const observable = this.store.select(isAuthenticated); + canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { + const url = state.url; - // redirect to sign in page if user is not authenticated - observable.subscribe((authenticated) => { - if (!authenticated) { - this.router.navigate(['/login']); - } - }); + return this.handleAuth(url); + } - return observable; + /** + * 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 | Promise | boolean { + 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.subscribe((authenticated) => { if (!authenticated) { + this.authService.redirectUrl = url; this.router.navigate(['/login']); } }); 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-info.model.ts b/src/app/core/auth/models/auth-info.model.ts deleted file mode 100644 index 8995f82235..0000000000 --- a/src/app/core/auth/models/auth-info.model.ts +++ /dev/null @@ -1,5 +0,0 @@ -export interface AuthInfo { - access_token?: string, - expires?: number, - expires_in?: number -} diff --git a/src/app/core/auth/models/auth-status.model.ts b/src/app/core/auth/models/auth-status.model.ts index 388405e87a..98a07c0e9d 100644 --- a/src/app/core/auth/models/auth-status.model.ts +++ b/src/app/core/auth/models/auth-status.model.ts @@ -1,4 +1,7 @@ +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 extends DSpaceObject { @@ -6,4 +9,9 @@ export class AuthStatus extends DSpaceObject { authenticated: boolean; + error?: AuthError; + + eperson: Eperson[]; + + token?: AuthTokenInfo } 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..1e798f1630 --- /dev/null +++ b/src/app/core/auth/models/auth-token-info.model.ts @@ -0,0 +1,11 @@ +export class AuthTokenInfo { + public accessToken: string; + public expires?: number; + + constructor(token: string, expiresIn?: number) { + this.accessToken = token.replace('Bearer ', ''); + if (expiresIn) { + this.expires = expiresIn * 1000 + Date.now(); + } + } +} 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/cache/response-cache.models.ts b/src/app/core/cache/response-cache.models.ts index 0a61a53df7..fd41c2258b 100644 --- a/src/app/core/cache/response-cache.models.ts +++ b/src/app/core/cache/response-cache.models.ts @@ -2,12 +2,16 @@ import { RequestError } from '../data/request.models'; import { PageInfo } from '../shared/page-info.model'; import { BrowseDefinition } from '../shared/browse-definition.model'; import { ConfigObject } from '../shared/config/config.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, ) { } } @@ -63,11 +67,31 @@ export class ConfigSuccessResponse extends RestResponse { } } -export class AuthSuccessResponse extends RestResponse { +export class AuthStatusResponse extends RestResponse { + public toCache = false; constructor( - public authResponse: any, + public response: AuthStatus, + public statusCode: string + ) { + super(true, statusCode); + } +} + +export class AuthSuccessResponse extends RestResponse { + public toCache = false; + constructor( + public response: AuthTokenInfo, + public statusCode: string + ) { + super(true, statusCode); + } +} + +export class AuthErrorResponse extends RestResponse { + public toCache = false; + constructor( + public response: any, public statusCode: string, - public pageInfo?: PageInfo ) { super(true, statusCode); } diff --git a/src/app/core/core.module.ts b/src/app/core/core.module.ts index 7ea4ef45d4..63f0165b06 100644 --- a/src/app/core/core.module.ts +++ b/src/app/core/core.module.ts @@ -41,6 +41,10 @@ import { UUIDService } from './shared/uuid.service'; import { AuthService } from './auth/auth.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 { AuthStorageService } from './auth/auth-storage.service'; const IMPORTS = [ CommonModule, @@ -60,7 +64,9 @@ const PROVIDERS = [ ApiService, AuthenticatedGuard, AuthRequestService, + AuthResponseParsingService, AuthService, + AuthStorageService, CommunityDataService, CollectionDataService, DSOResponseParsingService, @@ -83,7 +89,13 @@ const PROVIDERS = [ SubmissionFormsConfigService, SubmissionSectionsConfigService, UUIDService, - { provide: NativeWindowService, useFactory: NativeWindowFactory } + { provide: NativeWindowService, useFactory: NativeWindowFactory }, + // register TokenInterceptor as HttpInterceptor + { + provide: HTTP_INTERCEPTORS, + useClass: AuthInterceptor, + multi: true + } ]; @NgModule({ diff --git a/src/app/core/data/request.models.ts b/src/app/core/data/request.models.ts index b7c81eaa8e..c986c4b653 100644 --- a/src/app/core/data/request.models.ts +++ b/src/app/core/data/request.models.ts @@ -193,8 +193,8 @@ export class AuthPostRequest extends PostRequest { } export class AuthGetRequest extends GetRequest { - constructor(uuid: string, href: string) { - super(uuid, href); + constructor(uuid: string, href: string, public options?: HttpOptions) { + super(uuid, href, null, options); } getResponseParser(): GenericConstructor { diff --git a/src/app/core/data/request.service.ts b/src/app/core/data/request.service.ts index f589221e63..6d4393bade 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,9 @@ export class RequestService { .flatMap((uuid: string) => this.getByUUID(uuid)); } - configure(request: RestRequest): void { - if (request.method !== RestRequestMethod.Get || !this.isCachedOrPending(request)) { - this.dispatchRequest(request); + configure(request: RestRequest, overrideRequest: boolean = false): void { + if (request.method !== RestRequestMethod.Get || !this.isCachedOrPending(request) || overrideRequest) { + this.dispatchRequest(request, overrideRequest); } } @@ -101,10 +102,10 @@ export class RequestService { return isCached || isPending; } - private dispatchRequest(request: RestRequest) { + private dispatchRequest(request: RestRequest, overrideRequest: boolean) { this.store.dispatch(new RequestConfigureAction(request)); this.store.dispatch(new RequestExecuteAction(request.uuid)); - if (request.method === RestRequestMethod.Get) { + if (request.method === RestRequestMethod.Get && !overrideRequest) { 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..fe911e5635 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 1a5287d4b0..51efecf625 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 @@ -61,21 +61,16 @@ export class DSpaceRESTv2Service { requestOptions.body = body; requestOptions.observe = 'response'; if (options && options.headers) { - let headers = new HttpHeaders(); - headers = headers.append('Accept', 'application/json'); - headers = headers.append('Content-Type', 'application/x-www-form-urlencoded'); - // requestOptions.headers = new HttpHeaders().set('Content-Type', 'application/x-www-form-urlencoded'); - requestOptions.headers = headers; - /* const keys = options.headers.getAll(''); - keys.forEach((key) => { - requestOptions.headers.append(key, options.headers.get(key)); - })*/ + requestOptions.headers = Object.assign(new HttpHeaders(), options.headers); } if (options && options.responseType) { - // requestOptions.responseType = options.responseType; + requestOptions.responseType = options.responseType; } return this.http.request(method, url, requestOptions) - .map((res) => ({ payload: res.body, statusCode: res.statusText })) + .map((res) => { + console.log(res); + return ({ payload: res.body, headers: res.headers, statusCode: res.statusText }) + }) .catch((err) => { console.log('Error: ', err); return Observable.throw(err); From f91828a29a05c7bad4557633bfd2dc755c7450f9 Mon Sep 17 00:00:00 2001 From: Giuseppe Digilio Date: Fri, 9 Feb 2018 11:39:26 +0100 Subject: [PATCH 07/80] Add platform service --- src/app/shared/platform.service.spec.ts | 59 +++++++++++++++++++++++++ src/app/shared/platform.service.ts | 20 +++++++++ 2 files changed, 79 insertions(+) create mode 100644 src/app/shared/platform.service.spec.ts create mode 100644 src/app/shared/platform.service.ts diff --git a/src/app/shared/platform.service.spec.ts b/src/app/shared/platform.service.spec.ts new file mode 100644 index 0000000000..ddc4d13334 --- /dev/null +++ b/src/app/shared/platform.service.spec.ts @@ -0,0 +1,59 @@ +import { IPlatformService, PlatformService } from './platform.service' +import { async, TestBed } from '@angular/core/testing' +import { PLATFORM_ID } from '@angular/core' + +describe('PlatformService', () => { + let service: IPlatformService; + + describe('PlatformService browser', () => { + beforeEach(async(() => { + TestBed.configureTestingModule({ + providers: [ + PlatformService, + { provide: PLATFORM_ID, useValue: 'browser' } + ] + }) + })); + + beforeEach(async(() => { + service = TestBed.get(PlatformService) + })); + + afterEach(async(() => { + TestBed.resetTestingModule() + })); + + it('should construct', async(() => { + expect(service).not.toBeNull() + })); + + it('should be browser', async(() => { + expect(service.isBrowser).toEqual(true); + expect(service.isServer).toEqual(false) + })); + }); + + describe('PlatformService server', () => { + beforeEach(async(() => { + TestBed.configureTestingModule({ + providers: [ + PlatformService, + { provide: PLATFORM_ID, useValue: 'server' } + ] + }) + })); + + beforeEach(async(() => { + service = TestBed.get(PlatformService) + })); + + afterEach(async(() => { + TestBed.resetTestingModule() + })); + + it('should be server', async(() => { + expect(service.isServer).toEqual(true); + expect(service.isBrowser).toEqual(false) + })) + }) +}); diff --git a/src/app/shared/platform.service.ts b/src/app/shared/platform.service.ts new file mode 100644 index 0000000000..59c3830cce --- /dev/null +++ b/src/app/shared/platform.service.ts @@ -0,0 +1,20 @@ +import { Inject, Injectable, PLATFORM_ID } from '@angular/core' +import { isPlatformBrowser, isPlatformServer } from '@angular/common' + +export interface IPlatformService { + readonly isBrowser: boolean + readonly isServer: boolean +} + +@Injectable() +export class PlatformService implements IPlatformService { + constructor( @Inject(PLATFORM_ID) private platformId: any) { } + + public get isBrowser(): boolean { + return isPlatformBrowser(this.platformId) + } + + public get isServer(): boolean { + return isPlatformServer(this.platformId) + } +} From c9ab2cee2fbb651e57f9b283ef3d0713eba0f7ac Mon Sep 17 00:00:00 2001 From: Giuseppe Digilio Date: Fri, 9 Feb 2018 12:26:36 +0100 Subject: [PATCH 08/80] Grouped shared services into a directory --- .../search-filter/search-filter.component.ts | 2 +- .../search-filters/search-filter/search-filter.service.ts | 2 +- .../+search-page/search-service/search.service.spec.ts | 2 +- src/app/+search-page/search-service/search.service.ts | 2 +- src/app/app.component.spec.ts | 2 +- src/app/app.component.ts | 2 +- src/app/core/core.module.ts | 8 ++++---- src/app/pagenotfound/pagenotfound.component.ts | 2 +- src/app/shared/{ => services}/api.service.ts | 0 src/app/shared/{ => services}/platform.service.spec.ts | 0 src/app/shared/{ => services}/platform.service.ts | 0 src/app/shared/{ => services}/route.service.spec.ts | 0 src/app/shared/{ => services}/route.service.ts | 2 +- src/app/shared/{ => services}/server-response.service.ts | 0 src/app/shared/{ => services}/window.service.ts | 0 .../view-mode-switch/view-mode-switch.component.spec.ts | 2 +- 16 files changed, 13 insertions(+), 13 deletions(-) rename src/app/shared/{ => services}/api.service.ts (100%) rename src/app/shared/{ => services}/platform.service.spec.ts (100%) rename src/app/shared/{ => services}/platform.service.ts (100%) rename src/app/shared/{ => services}/route.service.spec.ts (100%) rename src/app/shared/{ => services}/route.service.ts (97%) rename src/app/shared/{ => services}/server-response.service.ts (100%) rename src/app/shared/{ => services}/window.service.ts (100%) diff --git a/src/app/+search-page/search-filters/search-filter/search-filter.component.ts b/src/app/+search-page/search-filters/search-filter/search-filter.component.ts index 08d72da984..c1f8e96c29 100644 --- a/src/app/+search-page/search-filters/search-filter/search-filter.component.ts +++ b/src/app/+search-page/search-filters/search-filter/search-filter.component.ts @@ -6,7 +6,7 @@ import { FacetValue } from '../../search-service/facet-value.model'; import { SearchFilterService } from './search-filter.service'; import { Observable } from 'rxjs/Observable'; import { slide } from '../../../shared/animations/slide'; -import { RouteService } from '../../../shared/route.service'; +import { RouteService } from '../../../shared/services/route.service'; import { first } from 'rxjs/operator/first'; /** 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 f3efc19b86..81ed2ba324 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 @@ -13,7 +13,7 @@ import { import { hasValue, } 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'; const filterStateSelector = (state: SearchFiltersState) => state.searchFilter; 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 65af3231f9..6620c1234c 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'; @Component({ template: '' }) class DummyComponent { } diff --git a/src/app/+search-page/search-service/search.service.ts b/src/app/+search-page/search-service/search.service.ts index c70fe22ce0..216e171dd0 100644 --- a/src/app/+search-page/search-service/search.service.ts +++ b/src/app/+search-page/search-service/search.service.ts @@ -13,7 +13,7 @@ import { PageInfo } from '../../core/shared/page-info.model'; import { hasValue, isNotEmpty } from '../../shared/empty.util'; import { ItemSearchResult } from '../../shared/object-collection/shared/item-search-result.model'; import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model'; -import { RouteService } from '../../shared/route.service'; +import { RouteService } from '../../shared/services/route.service'; import { SearchOptions } from '../search-options.model'; import { SearchResult } from '../search-result.model'; import { FacetValue } from './facet-value.model'; diff --git a/src/app/app.component.spec.ts b/src/app/app.component.spec.ts index 5c77b34549..327da7d9f6 100644 --- a/src/app/app.component.spec.ts +++ b/src/app/app.component.spec.ts @@ -26,7 +26,7 @@ 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'; diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 25bdde2d23..9098f7ca51 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -16,7 +16,7 @@ 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'; @Component({ selector: 'ds-app', diff --git a/src/app/core/core.module.ts b/src/app/core/core.module.ts index 63f0165b06..638aa4c4a5 100644 --- a/src/app/core/core.module.ts +++ b/src/app/core/core.module.ts @@ -14,7 +14,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 { CollectionDataService } from './data/collection-data.service'; import { CommunityDataService } from './data/community-data.service'; import { DSOResponseParsingService } from './data/dso-response-parsing.service'; @@ -28,12 +28,12 @@ import { RemoteDataBuildService } from './cache/builders/remote-data-build.servi import { RequestService } from './data/request.service'; import { ResponseCacheService } from './cache/response-cache.service'; import { RootResponseParsingService } from './data/root-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'; 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/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/platform.service.spec.ts b/src/app/shared/services/platform.service.spec.ts similarity index 100% rename from src/app/shared/platform.service.spec.ts rename to src/app/shared/services/platform.service.spec.ts diff --git a/src/app/shared/platform.service.ts b/src/app/shared/services/platform.service.ts similarity index 100% rename from src/app/shared/platform.service.ts rename to src/app/shared/services/platform.service.ts 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 97% rename from src/app/shared/route.service.ts rename to src/app/shared/services/route.service.ts index f24fa0d00d..e7e701255e 100644 --- a/src/app/shared/route.service.ts +++ b/src/app/shared/services/route.service.ts @@ -1,7 +1,7 @@ import { Injectable } from '@angular/core'; import { Observable } from 'rxjs/Observable'; import { ActivatedRoute, convertToParamMap, Params, } from '@angular/router'; -import { isNotEmpty } from './empty.util'; +import { isNotEmpty } from '../empty.util'; @Injectable() export class RouteService { 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/view-mode-switch/view-mode-switch.component.spec.ts b/src/app/shared/view-mode-switch/view-mode-switch.component.spec.ts index 541b1ed4c3..61ab3de634 100644 --- a/src/app/shared/view-mode-switch/view-mode-switch.component.spec.ts +++ b/src/app/shared/view-mode-switch/view-mode-switch.component.spec.ts @@ -10,7 +10,7 @@ import { SearchService } from '../../+search-page/search-service/search.service' import { ItemDataService } from './../../core/data/item-data.service'; import { ViewModeSwitchComponent } from './view-mode-switch.component'; import { ViewMode } from '../../+search-page/search-options.model'; -import { RouteService } from '../route.service'; +import { RouteService } from '../services/route.service'; @Component({ template: '' }) class DummyComponent { } From 64ad40b67639d192eec74d2bff317149b4e49296 Mon Sep 17 00:00:00 2001 From: Giuseppe Digilio Date: Fri, 9 Feb 2018 12:27:20 +0100 Subject: [PATCH 09/80] Added cookie service --- package.json | 2 + .../shared/services/cookie.service.spec.ts | 30 +++++++++ src/app/shared/services/cookie.service.ts | 62 +++++++++++++++++++ yarn.lock | 8 +++ 4 files changed, 102 insertions(+) create mode 100644 src/app/shared/services/cookie.service.spec.ts create mode 100644 src/app/shared/services/cookie.service.ts diff --git a/package.json b/package.json index b211892de9..dc62aff03d 100644 --- a/package.json +++ b/package.json @@ -86,6 +86,7 @@ "@nguniversal/express-engine": "5.0.0-beta.5", "@ngx-translate/core": "9.1.1", "@ngx-translate/http-loader": "2.0.1", + "@types/js-cookie": "^2.1.0", "angular-idle-preload": "2.0.4", "body-parser": "1.18.2", "bootstrap": "4.0.0-beta", @@ -98,6 +99,7 @@ "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", "methods": "1.1.2", 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..1bfddba99d --- /dev/null +++ b/src/app/shared/services/cookie.service.spec.ts @@ -0,0 +1,30 @@ +import { PlatformService } from './platform.service' +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, + PlatformService, + { 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..61762f7cd9 --- /dev/null +++ b/src/app/shared/services/cookie.service.ts @@ -0,0 +1,62 @@ +import { REQUEST } from '@nguniversal/express-engine/tokens' +import { PlatformService } from './platform.service' +import { Inject, Injectable } from '@angular/core' +import { Subject } from 'rxjs/Subject' +import { Observable } from 'rxjs/Observable' +import { CookieAttributes, getJSON, remove, set } 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 class CookieService implements ICookieService { + private readonly cookieSource = new Subject<{ readonly [key: string]: any }>(); + public readonly cookies$ = this.cookieSource.asObservable(); + + constructor(private platformService: PlatformService, @Inject(REQUEST) private req: any) { } + + public set(name: string, value: any, options?: CookieAttributes): void { + if (this.platformService.isBrowser) { + set(name, value, options); + this.updateSource() + } + } + + public remove(name: string, options?: CookieAttributes): void { + if (this.platformService.isBrowser) { + remove(name, options); + this.updateSource() + } + } + + public get(name: string): any { + if (this.platformService.isBrowser) { + return getJSON(name) + } else { + try { + return JSON.parse(this.req.cookies[name]) + } catch (err) { + return this.req ? this.req.cookies[name] : undefined + } + } + } + + public getAll(): any { + if (this.platformService.isBrowser) { + return getJSON() + } else { + if (this.req) { + return this.req.cookies + } + } + } + + private updateSource() { + this.cookieSource.next(this.getAll()) + } +} diff --git a/yarn.lock b/yarn.lock index 39ba0f596f..e51009776d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -193,6 +193,10 @@ version "2.8.4" resolved "https://registry.yarnpkg.com/@types/jasmine/-/jasmine-2.8.4.tgz#5528fb5e53f1b27594f81f18debb7eab8dc532cb" +"@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" @@ -4376,6 +4380,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" From b7cff01dab39948bda8dffb454f6739855d65ab3 Mon Sep 17 00:00:00 2001 From: Giuseppe Digilio Date: Sat, 10 Feb 2018 13:01:58 +0100 Subject: [PATCH 10/80] fixed auth module --- src/app/app.component.ts | 12 +++- src/app/core/auth/auth-request.service.ts | 1 + .../auth/auth-response-parsing.service.ts | 4 +- src/app/core/auth/auth-storage.service.ts | 34 ----------- src/app/core/auth/auth.actions.ts | 22 +++++++ src/app/core/auth/auth.effects.ts | 57 +++++++++++++++---- src/app/core/auth/auth.interceptor.ts | 11 +++- src/app/core/auth/auth.reducers.ts | 25 ++++---- src/app/core/auth/auth.service.ts | 50 ++++++---------- .../core/auth/models/auth-token-info.model.ts | 2 + src/app/core/cache/response-cache.service.ts | 5 ++ src/app/core/core.module.ts | 8 ++- .../auth-nav-menu/auth-nav-menu.component.ts | 9 ++- src/modules/app/browser-app.module.ts | 16 +++++- 14 files changed, 147 insertions(+), 109 deletions(-) delete mode 100644 src/app/core/auth/auth-storage.service.ts diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 9098f7ca51..4ce1df54b0 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -17,6 +17,9 @@ 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/services/window.service'; +import { CheckAuthenticationTokenAction } from './core/auth/auth.actions'; +import { isAuthenticated } from './core/auth/selectors'; +import { PlatformService } from './shared/services/platform.service'; @Component({ selector: 'ds-app', @@ -32,7 +35,8 @@ export class AppComponent implements OnInit { @Inject(NativeWindowService) private _window: NativeWindowRef, private translate: TranslateService, private store: Store, - private metadata: MetadataService + private metadata: MetadataService, + private platformService: PlatformService ) { // this language will be used as a fallback when a translation isn't found in the current language translate.setDefaultLang('en'); @@ -51,6 +55,12 @@ 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); + if (this.platformService.isServer) { + this.store.select(isAuthenticated) + .take(1) + .filter((authenticated) => !authenticated) + .subscribe((authenticated) => this.store.dispatch(new CheckAuthenticationTokenAction())); + } } @HostListener('window:resize', ['$event']) diff --git a/src/app/core/auth/auth-request.service.ts b/src/app/core/auth/auth-request.service.ts index 6202b6784d..f54aa9f0be 100644 --- a/src/app/core/auth/auth-request.service.ts +++ b/src/app/core/auth/auth-request.service.ts @@ -31,6 +31,7 @@ export class AuthRequestService extends HALEndpointService { protected fetchRequest(request: RestRequest): Observable { const [successResponse, errorResponse] = this.responseCache.get(request.href) .map((entry: ResponseCacheEntry) => entry.response) + .do(() => this.responseCache.remove(request.href)) .partition((response: RestResponse) => response.isSuccessful); return Observable.merge( errorResponse.flatMap((response: ErrorResponse) => diff --git a/src/app/core/auth/auth-response-parsing.service.ts b/src/app/core/auth/auth-response-parsing.service.ts index 1075cfe059..0215335f03 100644 --- a/src/app/core/auth/auth-response-parsing.service.ts +++ b/src/app/core/auth/auth-response-parsing.service.ts @@ -36,10 +36,10 @@ export class AuthResponseParsingService extends BaseResponseParsingService imple } parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse { - if (isNotEmpty(data.payload) && isNotEmpty(data.payload._links) && data.statusCode === '200') { + 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 if (isEmpty(data.payload) && isNotEmpty(data.headers.get('authorization')) && data.statusCode === '200') { + } else if (isEmpty(data.payload) && isNotEmpty(data.headers.get('authorization')) && (data.statusCode === '200' || data.statusCode === 'OK')) { return new AuthSuccessResponse(new AuthTokenInfo(data.headers.get('authorization')), data.statusCode); } else { return new AuthStatusResponse(data.payload as AuthStatus, data.statusCode); diff --git a/src/app/core/auth/auth-storage.service.ts b/src/app/core/auth/auth-storage.service.ts deleted file mode 100644 index 01e41d850b..0000000000 --- a/src/app/core/auth/auth-storage.service.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { Inject, Injectable, PLATFORM_ID } from '@angular/core'; -import { isPlatformBrowser } from '@angular/common'; - -/** - * The auth service. - */ -@Injectable() -export class AuthStorageService { - - constructor(@Inject(PLATFORM_ID) private platformId: string) {} - - public get(key: string): any { - let item = null; - if (isPlatformBrowser(this.platformId)) { - item = JSON.parse(localStorage.getItem(key)); - } - return item; - } - - public store(key: string, item: any) { - if (isPlatformBrowser(this.platformId)) { - localStorage.setItem(key, JSON.stringify(item)); - } - return true; - } - - public remove(key: string) { - if (isPlatformBrowser(this.platformId)) { - localStorage.removeItem(key); - } - return true; - } - -} diff --git a/src/app/core/auth/auth.actions.ts b/src/app/core/auth/auth.actions.ts index cf8ce68bd6..0528607d55 100644 --- a/src/app/core/auth/auth.actions.ts +++ b/src/app/core/auth/auth.actions.ts @@ -15,6 +15,8 @@ export const AuthActionTypes = { 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'), RESET_ERROR: type('dspace/auth/RESET_ERROR'), LOG_OUT: type('dspace/auth/LOG_OUT'), LOG_OUT_ERROR: type('dspace/auth/LOG_OUT_ERROR'), @@ -116,6 +118,24 @@ export class AuthenticationSuccessAction implements Action { } } +/** + * 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; +} + /** * Reset error. * @class ResetAuthenticationErrorAction @@ -215,6 +235,8 @@ export type AuthActions | AuthenticatedSuccessAction | AuthenticationErrorAction | AuthenticationSuccessAction + | CheckAuthenticationTokenAction + | CheckAuthenticationTokenErrorAction | RegistrationAction | RegistrationErrorAction | RegistrationSuccessAction; diff --git a/src/app/core/auth/auth.effects.ts b/src/app/core/auth/auth.effects.ts index aa7503fe4c..3d1bd43854 100644 --- a/src/app/core/auth/auth.effects.ts +++ b/src/app/core/auth/auth.effects.ts @@ -2,7 +2,7 @@ import { Injectable } from '@angular/core'; // import @ngrx import { Effect, Actions } from '@ngrx/effects'; -import { Action } from '@ngrx/store'; +import { Action, Store } from '@ngrx/store'; // import rxjs import { Observable } from 'rxjs/Observable'; @@ -16,7 +16,7 @@ import { AuthenticatedErrorAction, AuthenticatedSuccessAction, AuthenticationErrorAction, - AuthenticationSuccessAction, LogOutAction, + AuthenticationSuccessAction, CheckAuthenticationTokenAction, CheckAuthenticationTokenErrorAction, LogOutAction, LogOutErrorAction, LogOutSuccessAction, RegistrationAction, RegistrationErrorAction, @@ -24,6 +24,10 @@ import { } 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 { @@ -46,12 +50,7 @@ export class AuthEffects { 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({dispatch: false}) - public logOutSuccess: Observable = this.actions$ - .ofType(AuthActionTypes.LOG_OUT_SUCCESS) - .do((action: LogOutSuccessAction) => this.authService.removeToken()); + .map((action: AuthenticationSuccessAction) => new AuthenticatedAction(action.payload)); @Effect() public authenticated: Observable = this.actions$ @@ -62,6 +61,20 @@ export class AuthEffects { .catch((error) => Observable.of(new AuthenticatedErrorAction(error))); }); + @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.checkAuthenticationToken() + .map((token: AuthTokenInfo) => new AuthenticatedAction(token)) + .catch((error) => Observable.of(new CheckAuthenticationTokenErrorAction())); + }); + @Effect() public createUser: Observable = this.actions$ .ofType(AuthActionTypes.REGISTRATION) @@ -72,21 +85,41 @@ export class AuthEffects { .catch((error) => Observable.of(new RegistrationErrorAction(error))); }); + /** + * When the store is rehydrated in the browser, + * clear a possible invalid token + */ + @Effect({dispatch: false}) + public clearInvalidTokenOnRehydrate = this.actions$ + .ofType(StoreActionTypes.REHYDRATE) + .switchMap(() => { + return this.store.select(isAuthenticated) + .take(1) + .filter((authenticated) => !authenticated) + .do(() => this.authService.removeToken()); + }); + @Effect() - public signOut: Observable = this.actions$ + public logOut: Observable = this.actions$ .ofType(AuthActionTypes.LOG_OUT) - .switchMap((action: LogOutAction) => { - return this.authService.signout() + .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((action: LogOutSuccessAction) => this.authService.removeToken()); + /** * @constructor * @param {Actions} actions$ * @param {AuthService} authService */ constructor(private actions$: Actions, - private authService: AuthService) { + private authService: AuthService, + private store: Store) { } } diff --git a/src/app/core/auth/auth.interceptor.ts b/src/app/core/auth/auth.interceptor.ts index 40fd0d3836..e8406566bc 100644 --- a/src/app/core/auth/auth.interceptor.ts +++ b/src/app/core/auth/auth.interceptor.ts @@ -23,6 +23,10 @@ export class AuthInterceptor implements HttpInterceptor { return status === 401 || status === 403; } + private isAuthRequest(url: string): boolean { + return url.endsWith('/authn/login') || url.endsWith('/authn/logout') || url.endsWith('/authn/status'); + } + private isLoginResponse(url: string): boolean { return url.endsWith('/authn/login'); } @@ -40,7 +44,7 @@ export class AuthInterceptor implements HttpInterceptor { authStatus.token = new AuthTokenInfo(accessToken); } else { authStatus.authenticated = false; - authStatus.error = JSON.parse(error); + authStatus.error = isNotEmpty(error) ? JSON.parse(error) : null; } return authStatus; } @@ -53,11 +57,11 @@ export class AuthInterceptor implements HttpInterceptor { const Authorization = authService.getAuthHeader(); let authReq; - if (isNotEmpty(Authorization)) { + if (!this.isAuthRequest(req.url) && isNotEmpty(Authorization)) { // Clone the request to add the new header. authReq = req.clone({headers: req.headers.set('authorization', Authorization)}); } else { - authReq = req.clone(); + authReq = req; } // Pass on the cloned request instead of the original request. @@ -67,6 +71,7 @@ export class AuthInterceptor implements HttpInterceptor { let authRes: HttpResponse; if (this.isLoginResponse(response.url)) { const token = response.headers.get('authorization'); + const expires = response.headers.get('expires'); authRes = response.clone({body: this.makeAuthStatusObject(true, token)}); } else { authRes = response.clone({body: this.makeAuthStatusObject(false)}); diff --git a/src/app/core/auth/auth.reducers.ts b/src/app/core/auth/auth.reducers.ts index 645541aa8e..31079767e5 100644 --- a/src/app/core/auth/auth.reducers.ts +++ b/src/app/core/auth/auth.reducers.ts @@ -6,7 +6,6 @@ import { // import models import { Eperson } from '../eperson/models/eperson.model'; -import { AuthTokenInfo } from './models/auth-token-info.model'; /** * The auth state. @@ -26,9 +25,6 @@ export interface AuthState { // true when loading loading: boolean; - // access token - token?: AuthTokenInfo; - // the authenticated user user?: Eperson; } @@ -61,7 +57,8 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut return Object.assign({}, state, { authenticated: false, error: (action as AuthenticationErrorAction).payload.message, - loaded: true + loaded: true, + loading: false }); case AuthActionTypes.AUTHENTICATED_SUCCESS: @@ -82,15 +79,16 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut }); case AuthActionTypes.AUTHENTICATE_SUCCESS: - const token: AuthTokenInfo = (action as AuthenticationSuccessAction).payload; - - // verify token is not null - if (token === null) { - return state; - } + return state; + case AuthActionTypes.CHECK_AUTHENTICATION_TOKEN: return Object.assign({}, state, { - token: token + loading: true + }); + + case AuthActionTypes.CHECK_AUTHENTICATION_TOKEN_ERROR: + return Object.assign({}, state, { + loading: false }); case AuthActionTypes.REGISTRATION_SUCCESS: @@ -117,8 +115,7 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut error: undefined, loaded: false, loading: false, - user: undefined, - token: undefined + user: undefined }); case AuthActionTypes.REGISTRATION: diff --git a/src/app/core/auth/auth.service.ts b/src/app/core/auth/auth.service.ts index a5373e1afd..62f5aa5ba7 100644 --- a/src/app/core/auth/auth.service.ts +++ b/src/app/core/auth/auth.service.ts @@ -7,34 +7,9 @@ import { AuthRequestService } from './auth-request.service'; import { HttpHeaders } from '@angular/common/http'; import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service'; import { AuthStatus } from './models/auth-status.model'; -import { AuthTokenInfo } from './models/auth-token-info.model'; +import { AuthTokenInfo, TOKENITEM } from './models/auth-token-info.model'; import { isNotEmpty, isNotNull } from '../../shared/empty.util'; -import { AuthStorageService } from './auth-storage.service'; - -export const MOCK_USER = new Eperson(); -MOCK_USER.id = '92a59227-ccf7-46da-9776-86c3fc64147f'; -MOCK_USER.uuid = '92a59227-ccf7-46da-9776-86c3fc64147f'; -MOCK_USER.name = 'andrea.bollini@4science.it'; -MOCK_USER.email = 'andrea.bollini@4science.it'; -MOCK_USER.metadata = [ - { - key: 'eperson.firstname', - value: 'Andrea', - language: null - }, - { - key: 'eperson.lastname', - value: 'Bollini', - language: null - }, - { - key: 'eperson.language', - value: 'en', - language: null - } -]; - -export const TOKENITEM = 'dsAuthInfo'; +import { CookieService } from '../../shared/services/cookie.service'; /** * The auth service. @@ -54,7 +29,7 @@ export class AuthService { */ private _redirectUrl: string; - constructor(private authRequestService: AuthRequestService, private storage: AuthStorageService) { + constructor(private authRequestService: AuthRequestService, private storage: CookieService) { } /** @@ -76,7 +51,7 @@ export class AuthService { let headers = new HttpHeaders(); headers = headers.append('Content-Type', 'application/x-www-form-urlencoded'); options.headers = headers; - options.responseType = 'text'; + // options.responseType = 'text'; return this.authRequestService.postToEndpoint('login', body, options) .map((status: AuthStatus) => { if (status.authenticated) { @@ -101,9 +76,7 @@ export class AuthService { * @returns {User} */ public authenticatedUser(token: AuthTokenInfo): Observable { - // Normally you would do an HTTP request to determine if - // the user has an existing auth session on the server - // but, let's just return the mock user for this example. + // 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'); @@ -121,6 +94,14 @@ export class AuthService { }); } + /** + * Checks if token is present into storage + */ + public checkAuthenticationToken(): Observable { + const token = this.getToken(); + return isNotEmpty(token) ? Observable.of(token) : Observable.throw(false); + } + /** * Create a new user * @returns {User} @@ -137,7 +118,7 @@ export class AuthService { * End session * @returns {Observable} */ - public signout(): Observable { + public logout(): Observable { // Normally you would do an HTTP request sign end the session // but, let's just return an observable of true. let headers = new HttpHeaders(); @@ -168,11 +149,12 @@ export class AuthService { public storeToken(token: AuthTokenInfo) { // Save authentication token info - return this.storage.store(TOKENITEM, JSON.stringify(token)); + return this.storage.set(TOKENITEM, token); } public removeToken() { // Remove authentication token info + console.log('REMOVE!!!!'); return this.storage.remove(TOKENITEM); } diff --git a/src/app/core/auth/models/auth-token-info.model.ts b/src/app/core/auth/models/auth-token-info.model.ts index 1e798f1630..e6f6afc872 100644 --- a/src/app/core/auth/models/auth-token-info.model.ts +++ b/src/app/core/auth/models/auth-token-info.model.ts @@ -1,3 +1,5 @@ +export const TOKENITEM = 'dsAuthInfo'; + export class AuthTokenInfo { public accessToken: string; public expires?: number; 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.module.ts b/src/app/core/core.module.ts index 638aa4c4a5..9c57026975 100644 --- a/src/app/core/core.module.ts +++ b/src/app/core/core.module.ts @@ -44,7 +44,8 @@ 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 { AuthStorageService } from './auth/auth-storage.service'; +import { CookieService } from '../shared/services/cookie.service'; +import { PlatformService } from '../shared/services/platform.service'; const IMPORTS = [ CommonModule, @@ -66,9 +67,9 @@ const PROVIDERS = [ AuthRequestService, AuthResponseParsingService, AuthService, - AuthStorageService, CommunityDataService, CollectionDataService, + CookieService, DSOResponseParsingService, DSpaceRESTv2Service, HostWindowService, @@ -76,6 +77,7 @@ const PROVIDERS = [ MetadataService, ObjectCacheService, PaginationComponentOptions, + PlatformService, RemoteDataBuildService, RequestService, ResponseCacheService, @@ -90,7 +92,7 @@ const PROVIDERS = [ SubmissionSectionsConfigService, UUIDService, { provide: NativeWindowService, useFactory: NativeWindowFactory }, - // register TokenInterceptor as HttpInterceptor + // register AuthInterceptor as HttpInterceptor { provide: HTTP_INTERCEPTORS, useClass: AuthInterceptor, 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 index 453f68ea86..b9e46c6159 100644 --- a/src/app/shared/auth-nav-menu/auth-nav-menu.component.ts +++ b/src/app/shared/auth-nav-menu/auth-nav-menu.component.ts @@ -35,18 +35,17 @@ export class AuthNavMenuComponent implements OnDestroy, OnInit { protected subs: Subscription[] = []; constructor( - private appStore: Store, - private coreStore: Store, + private store: Store, public windowService: HostWindowService) { } ngOnInit(): void { // set loading - this.isAuthenticated = this.coreStore.select(isAuthenticated); + this.isAuthenticated = this.store.select(isAuthenticated); - this.user = this.appStore.select(getAuthenticatedUser); + this.user = this.store.select(getAuthenticatedUser); - this.subs.push(this.appStore.select(routerStateSelector) + this.subs.push(this.store.select(routerStateSelector) .filter((router: RouterReducerState) => isNotUndefined(router)) .subscribe((router: RouterReducerState) => { this.showAuth = router.state.url !== '/login'; diff --git a/src/modules/app/browser-app.module.ts b/src/modules/app/browser-app.module.ts index df77dad74f..ffaa30e29e 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'; @@ -15,10 +16,16 @@ 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'; +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: [ @@ -45,6 +52,13 @@ export function createTranslateLoader(http: HttpClient) { }), AppModule ], + providers: [ + { + provide: REQUEST, + useFactory: getRequest, + deps: [TransferState] + } + ] }) export class BrowserAppModule { constructor( From 8df514f39c1b68cd2ebdeee28cd40b20a4847114 Mon Sep 17 00:00:00 2001 From: Giuseppe Digilio Date: Mon, 12 Feb 2018 19:13:42 +0100 Subject: [PATCH 11/80] Auth module improvement --- src/app/app.reducer.ts | 2 + src/app/core/auth/auth.actions.ts | 34 +++-- src/app/core/auth/auth.effects.ts | 13 +- src/app/core/auth/auth.interceptor.ts | 29 +++-- src/app/core/auth/auth.reducers.ts | 37 +++++- src/app/core/auth/auth.service.ts | 119 ++++++++++++++---- src/app/core/auth/authenticated.guard.ts | 5 +- src/app/core/auth/selectors.ts | 9 ++ .../auth-nav-menu.component.html | 4 +- .../auth-nav-menu.component.scss | 7 ++ .../auth-nav-menu/auth-nav-menu.component.ts | 4 +- src/app/shared/log-in/log-in.component.html | 5 +- src/app/shared/log-in/log-in.component.ts | 48 ++++--- src/app/shared/log-out/log-out.component.html | 4 +- src/app/shared/log-out/log-out.component.ts | 7 -- 15 files changed, 238 insertions(+), 89 deletions(-) diff --git a/src/app/app.reducer.ts b/src/app/app.reducer.ts index e69f29ae52..2b37ea6a79 100644 --- a/src/app/app.reducer.ts +++ b/src/app/app.reducer.ts @@ -28,3 +28,5 @@ export const appReducers: ActionReducerMap = { searchSidebar: sidebarReducer, searchFilter: filterReducer }; + +export const routerStateSelector = (state: AppState) => state.router; diff --git a/src/app/core/auth/auth.actions.ts b/src/app/core/auth/auth.actions.ts index 0528607d55..b9a71b71a1 100644 --- a/src/app/core/auth/auth.actions.ts +++ b/src/app/core/auth/auth.actions.ts @@ -17,6 +17,7 @@ export const AuthActionTypes = { 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: type('dspace/auth/REDIRECT'), RESET_ERROR: type('dspace/auth/RESET_ERROR'), LOG_OUT: type('dspace/auth/LOG_OUT'), LOG_OUT_ERROR: type('dspace/auth/LOG_OUT_ERROR'), @@ -136,15 +137,6 @@ export class CheckAuthenticationTokenErrorAction implements Action { public type: string = AuthActionTypes.CHECK_AUTHENTICATION_TOKEN_ERROR; } -/** - * Reset error. - * @class ResetAuthenticationErrorAction - * @implements {Action} - */ -export class ResetAuthenticationErrorAction implements Action { - public type: string = AuthActionTypes.RESET_ERROR; -} - /** * Sign out. * @class LogOutAction @@ -179,6 +171,20 @@ export class LogOutSuccessAction implements Action { constructor(public payload?: any) {} } +/** + * Redirect to login page when token is expired. + * @class RedirectWhenTokenExpiredAction + * @implements {Action} + */ +export class RedirectWhenTokenExpiredAction implements Action { + public type: string = AuthActionTypes.REDIRECT; + payload: string; + + constructor(message: string) { + this.payload = message ; + } +} + /** * Sign up. * @class RegistrationAction @@ -221,6 +227,15 @@ export class RegistrationSuccessAction implements Action { } } +/** + * Reset error. + * @class ResetAuthenticationErrorAction + * @implements {Action} + */ +export class ResetAuthenticationErrorAction implements Action { + public type: string = AuthActionTypes.RESET_ERROR; +} + /* tslint:enable:max-classes-per-file */ /** @@ -237,6 +252,7 @@ export type AuthActions | AuthenticationSuccessAction | CheckAuthenticationTokenAction | CheckAuthenticationTokenErrorAction + | RedirectWhenTokenExpiredAction | RegistrationAction | RegistrationErrorAction | RegistrationSuccessAction; diff --git a/src/app/core/auth/auth.effects.ts b/src/app/core/auth/auth.effects.ts index 3d1bd43854..2343862cbb 100644 --- a/src/app/core/auth/auth.effects.ts +++ b/src/app/core/auth/auth.effects.ts @@ -16,7 +16,7 @@ import { AuthenticatedErrorAction, AuthenticatedSuccessAction, AuthenticationErrorAction, - AuthenticationSuccessAction, CheckAuthenticationTokenAction, CheckAuthenticationTokenErrorAction, LogOutAction, + AuthenticationSuccessAction, CheckAuthenticationTokenErrorAction, LogOutErrorAction, LogOutSuccessAction, RegistrationAction, RegistrationErrorAction, @@ -87,7 +87,7 @@ export class AuthEffects { /** * When the store is rehydrated in the browser, - * clear a possible invalid token + * clear a possible invalid token or authentication errors */ @Effect({dispatch: false}) public clearInvalidTokenOnRehydrate = this.actions$ @@ -96,7 +96,8 @@ export class AuthEffects { return this.store.select(isAuthenticated) .take(1) .filter((authenticated) => !authenticated) - .do(() => this.authService.removeToken()); + .do(() => this.authService.removeToken()) + .do(() => this.authService.resetAuthenticationError()); }); @Effect() @@ -113,10 +114,16 @@ export class AuthEffects { .ofType(AuthActionTypes.LOG_OUT_SUCCESS) .do((action: LogOutSuccessAction) => this.authService.removeToken()); + @Effect({dispatch: false}) + public redirectToLogin: Observable = this.actions$ + .ofType(AuthActionTypes.REDIRECT) + .do(() => this.authService.redirectToLogin()); + /** * @constructor * @param {Actions} actions$ * @param {AuthService} authService + * @param {Store} store */ constructor(private actions$: Actions, private authService: AuthService, diff --git a/src/app/core/auth/auth.interceptor.ts b/src/app/core/auth/auth.interceptor.ts index e8406566bc..49a1213636 100644 --- a/src/app/core/auth/auth.interceptor.ts +++ b/src/app/core/auth/auth.interceptor.ts @@ -14,10 +14,13 @@ import { AuthType } from './auth-type'; import { ResourceType } from '../shared/resource-type'; import { AuthTokenInfo } from './models/auth-token-info.model'; import { isNotEmpty } from '../../shared/empty.util'; +import { AppState } from '../../app.reducer'; +import { RedirectWhenTokenExpiredAction } from './auth.actions'; +import { Store } from '@ngrx/store'; @Injectable() export class AuthInterceptor implements HttpInterceptor { - constructor(private inj: Injector, private router: Router) { } + constructor(private inj: Injector, private store: Store) { } private isUnauthorized(status: number): boolean { return status === 401 || status === 403; @@ -84,15 +87,21 @@ export class AuthInterceptor implements HttpInterceptor { .catch((error, caught) => { // Intercept an unauthorized error response if (error instanceof HttpErrorResponse && this.isUnauthorized(error.status)) { - // 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); + // Checks if is a response from a request to an authentication endpoint + if (this.isAuthRequest(error.url)) { + // 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 { + // Redirect to the login route + this.store.dispatch(new RedirectWhenTokenExpiredAction('Your session has expired. Please log in again.')); + } } else { // Return error response as is. return Observable.throw(error); diff --git a/src/app/core/auth/auth.reducers.ts b/src/app/core/auth/auth.reducers.ts index 31079767e5..1aa800daed 100644 --- a/src/app/core/auth/auth.reducers.ts +++ b/src/app/core/auth/auth.reducers.ts @@ -1,7 +1,7 @@ // import actions import { AuthActions, AuthActionTypes, AuthenticatedSuccessAction, AuthenticationErrorAction, - AuthenticationSuccessAction, LogOutErrorAction + AuthenticationSuccessAction, LogOutErrorAction, RedirectWhenTokenExpiredAction } from './auth.actions'; // import models @@ -25,6 +25,9 @@ export interface AuthState { // true when loading loading: boolean; + // info message + message?: string; + // the authenticated user user?: Eperson; } @@ -33,7 +36,7 @@ export interface AuthState { * The initial state. */ const initialState: AuthState = { - authenticated: null, + authenticated: false, loaded: false, loading: false }; @@ -50,7 +53,8 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut case AuthActionTypes.AUTHENTICATE: return Object.assign({}, state, { error: undefined, - loading: true + loading: true, + message: undefined }); case AuthActionTypes.AUTHENTICATED_ERROR: @@ -67,6 +71,7 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut loaded: true, error: undefined, loading: false, + message: undefined, user: (action as AuthenticatedSuccessAction).payload.user }); @@ -96,10 +101,11 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut case AuthActionTypes.RESET_ERROR: return Object.assign({}, state, { - authenticated: null, + authenticated: false, error: undefined, loaded: false, loading: false, + message: undefined, }); case AuthActionTypes.LOG_OUT_ERROR: @@ -115,6 +121,16 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut error: undefined, loaded: false, loading: false, + message: undefined, + user: undefined + }); + + case AuthActionTypes.REDIRECT: + return Object.assign({}, state, { + authenticated: false, + loaded: false, + loading: false, + message: (action as RedirectWhenTokenExpiredAction).payload, user: undefined }); @@ -122,7 +138,8 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut return Object.assign({}, state, { authenticated: false, error: undefined, - loading: true + loading: true, + message: undefined }); default: @@ -158,10 +175,18 @@ export const getAuthenticatedUser = (state: AuthState) => state.user; * Returns the authentication error. * @function getAuthenticationError * @param {State} state - * @returns {Error} + * @returns {String} */ export const getAuthenticationError = (state: AuthState) => state.error; +/** + * Returns the authentication info message. + * @function getAuthenticationError + * @param {State} state + * @returns {String} + */ +export const getAuthenticationMessage = (state: AuthState) => state.message; + /** * Returns true if request is in progress. * @function isLoading diff --git a/src/app/core/auth/auth.service.ts b/src/app/core/auth/auth.service.ts index 62f5aa5ba7..3b76c0c1a6 100644 --- a/src/app/core/auth/auth.service.ts +++ b/src/app/core/auth/auth.service.ts @@ -8,9 +8,16 @@ import { HttpHeaders } from '@angular/common/http'; 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 { isNotEmpty, isNotNull } from '../../shared/empty.util'; +import { isNotEmpty, isNotNull, isNotUndefined } from '../../shared/empty.util'; import { CookieService } from '../../shared/services/cookie.service'; +import { ActivatedRoute, Router } from '@angular/router'; +import { isAuthenticated } from './selectors'; +import { AppState, routerStateSelector } from '../../app.reducer'; +import { Store } from '@ngrx/store'; +import { ResetAuthenticationErrorAction } from './auth.actions'; +import { RouterReducerState } from '@ngrx/router-store'; +export const LOGIN_ROUTE = '/login'; /** * The auth service. */ @@ -21,7 +28,7 @@ export class AuthService { * True if authenticated * @type boolean */ - private _authenticated = false; + private _authenticated: boolean; /** * The url to redirect after login @@ -29,7 +36,27 @@ export class AuthService { */ private _redirectUrl: string; - constructor(private authRequestService: AuthRequestService, private storage: CookieService) { + constructor(private route: ActivatedRoute, + private authRequestService: AuthRequestService, + private router: Router, + private storage: CookieService, + private 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 it + this.store.select(routerStateSelector) + .filter((routerState: RouterReducerState) => isNotUndefined(routerState)) + .filter((routerState: RouterReducerState) => + (routerState.state.url !== LOGIN_ROUTE) + && isNotEmpty(this._redirectUrl) + && (routerState.state.url !== this._redirectUrl)) + .distinctUntilChanged() + .subscribe((routerState: RouterReducerState) => { + this._redirectUrl = ''; + }) } /** @@ -40,18 +67,12 @@ export class AuthService { * @returns {Observable} The authenticated user observable. */ public authenticate(user: string, password: string): Observable { - // Normally you would do an HTTP request to determine to - // attempt authenticating the user using the supplied credentials. - // const body = `user=${user}&password=${password}`; - // const body = encodeURI('password=test&user=vera.aloe@mailinator.com'); - // const body = [{user}, {password}]; - // const body = encodeURI('password=' + password.toString() + '&user=' + user.toString()); + // Attempt authenticating the user using the supplied credentials. const body = encodeURI(`password=${password}&user=${user}`); const options: HttpOptions = Object.create({}); let headers = new HttpHeaders(); headers = headers.append('Content-Type', 'application/x-www-form-urlencoded'); options.headers = headers; - // options.responseType = 'text'; return this.authRequestService.postToEndpoint('login', body, options) .map((status: AuthStatus) => { if (status.authenticated) { @@ -67,8 +88,8 @@ export class AuthService { * Determines if the user is authenticated * @returns {Observable} */ - public authenticated(): Observable { - return Observable.of(this._authenticated); + public isAuthenticated(): Observable { + return this.store.select(isAuthenticated); } /** @@ -85,10 +106,8 @@ export class AuthService { return this.authRequestService.getRequest('status', options) .map((status: AuthStatus) => { if (status.authenticated) { - this._authenticated = true; return status.eperson[0]; } else { - this._authenticated = false; throw(new Error('Not authenticated')); } }); @@ -102,6 +121,13 @@ export class AuthService { return isNotEmpty(token) ? Observable.of(token) : Observable.throw(false); } + /** + * Clear authentication errors + */ + public resetAuthenticationError(): void { + this.store.dispatch(new ResetAuthenticationErrorAction()); + } + /** * Create a new user * @returns {User} @@ -119,15 +145,13 @@ export class AuthService { * @returns {Observable} */ public logout(): Observable { - // Normally you would do an HTTP request sign end the session - // but, let's just return an observable of true. + // 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) { - this._authenticated = false; return true; } else { throw(new Error('Invalid email or password')); @@ -136,32 +160,77 @@ export class AuthService { } + /** + * Retrieve authentication token info and make authorization header + * @returns {string} + */ public getAuthHeader(): string { - // Retrieve authentication token info - const token = this.storage.get(TOKENITEM); - return (isNotNull(token) && this._authenticated) ? `Bearer ${token.accessToken}` : ''; + const token = this.getToken(); + return (this._authenticated && isNotNull(token)) ? `Bearer ${token.accessToken}` : ''; } + /** + * Get authentication token info + * @returns {AuthTokenInfo} + */ public getToken(): AuthTokenInfo { - // Retrieve authentication token info - return this.storage.get(TOKENITEM); + // Retrieve authentication token info and check if is valid + const token = this.storage.get(TOKENITEM); + if (isNotEmpty(token) && token.hasOwnProperty('accessToken') && isNotEmpty(token.accessToken)) { + return token; + } else { + return null; + } } + /** + * Save authentication token info + * + * @param {AuthTokenInfo} token The token to save + * @returns {AuthTokenInfo} + */ public storeToken(token: AuthTokenInfo) { - // Save authentication token info return this.storage.set(TOKENITEM, token); } + /** + * Remove authentication token info + */ public removeToken() { - // Remove authentication token info - console.log('REMOVE!!!!'); return this.storage.remove(TOKENITEM); } + /** + * Redirect to the login route + */ + public redirectToLogin() { + this.router.navigate(['/login']); + } + + /** + * Redirect to the route navigated before the login + */ + public redirectToPreviousUrl() { + if (isNotEmpty(this._redirectUrl)) { + const url = this._redirectUrl; + // Clear url + this._redirectUrl = null; + this.router.navigate([url]); + } else { + this.router.navigate(['/']); + } + } + + /** + * Get redirect url + */ get redirectUrl(): string { return this._redirectUrl; } + /** + * Set redirect url + */ set redirectUrl(value: string) { this._redirectUrl = value; } diff --git a/src/app/core/auth/authenticated.guard.ts b/src/app/core/auth/authenticated.guard.ts index cd39981a1f..29db8bc46d 100644 --- a/src/app/core/auth/authenticated.guard.ts +++ b/src/app/core/auth/authenticated.guard.ts @@ -6,7 +6,7 @@ import { Store } from '@ngrx/store'; // reducers import { CoreState } from '../core.reducers'; -import { isAuthenticated } from './selectors'; +import { isAuthenticated, isAuthenticationLoading } from './selectors'; import { AuthService } from './auth.service'; /** @@ -50,6 +50,7 @@ export class AuthenticatedGuard implements CanActivate, CanLoad { } private handleAuth(url: string): Observable { + console.log('handleAuth', url) // get observable const observable = this.store.select(isAuthenticated); @@ -57,7 +58,7 @@ export class AuthenticatedGuard implements CanActivate, CanLoad { observable.subscribe((authenticated) => { if (!authenticated) { this.authService.redirectUrl = url; - this.router.navigate(['/login']); + this.authService.redirectToLogin(); } }); diff --git a/src/app/core/auth/selectors.ts b/src/app/core/auth/selectors.ts index a260f84762..5cf4da58dc 100644 --- a/src/app/core/auth/selectors.ts +++ b/src/app/core/auth/selectors.ts @@ -38,6 +38,15 @@ export const getAuthenticatedUser = createSelector(getAuthState, auth.getAuthent */ export const getAuthenticationError = createSelector(getAuthState, auth.getAuthenticationError); +/** + * Returns the authentication info message. + * @function getAuthenticationError + * @param {AuthState} state + * @param {any} props + * @return {Error} + */ +export const getAuthenticationMessage = createSelector(getAuthState, auth.getAuthenticationMessage); + /** * Returns true if the user is authenticated * @function isAuthenticated 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 index 34db6a2130..e66e75be4c 100644 --- a/src/app/shared/auth-nav-menu/auth-nav-menu.component.html +++ b/src/app/shared/auth-nav-menu/auth-nav-menu.component.html @@ -2,7 +2,7 @@