diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts
index fda558a5dd..2927cd4e65 100644
--- a/src/app/app-routing.module.ts
+++ b/src/app/app-routing.module.ts
@@ -63,16 +63,29 @@ export function getDSOPath(dso: DSpaceObject): string {
{ path: COMMUNITY_MODULE_PATH, loadChildren: './+community-page/community-page.module#CommunityPageModule' },
{ path: COLLECTION_MODULE_PATH, loadChildren: './+collection-page/collection-page.module#CollectionPageModule' },
{ path: ITEM_MODULE_PATH, loadChildren: './+item-page/item-page.module#ItemPageModule' },
- { path: 'mydspace', loadChildren: './+my-dspace-page/my-dspace-page.module#MyDSpacePageModule', canActivate: [AuthenticatedGuard] },
+ {
+ path: 'mydspace',
+ loadChildren: './+my-dspace-page/my-dspace-page.module#MyDSpacePageModule',
+ canActivate: [AuthenticatedGuard]
+ },
{ path: 'search', loadChildren: './+search-page/search-page.module#SearchPageModule' },
{ path: 'browse', loadChildren: './+browse-by/browse-by.module#BrowseByModule'},
{ path: ADMIN_MODULE_PATH, loadChildren: './+admin/admin.module#AdminModule', canActivate: [AuthenticatedGuard] },
{ path: 'login', loadChildren: './+login-page/login-page.module#LoginPageModule' },
{ path: 'logout', loadChildren: './+logout-page/logout-page.module#LogoutPageModule' },
{ path: 'submit', loadChildren: './+submit-page/submit-page.module#SubmitPageModule' },
- { path: 'workspaceitems', loadChildren: './+workspaceitems-edit-page/workspaceitems-edit-page.module#WorkspaceitemsEditPageModule' },
- { path: 'workflowitems', loadChildren: './+workflowitems-edit-page/workflowitems-edit-page.module#WorkflowItemsEditPageModule' },
- { path: PROFILE_MODULE_PATH, loadChildren: './profile-page/profile-page.module#ProfilePageModule', canActivate: [AuthenticatedGuard] },
+ {
+ path: 'workspaceitems',
+ loadChildren: './+workspaceitems-edit-page/workspaceitems-edit-page.module#WorkspaceitemsEditPageModule'
+ },
+ {
+ path: 'workflowitems',
+ loadChildren: './+workflowitems-edit-page/workflowitems-edit-page.module#WorkflowItemsEditPageModule'
+ },
+ {
+ path: PROFILE_MODULE_PATH,
+ loadChildren: './profile-page/profile-page.module#ProfilePageModule', canActivate: [AuthenticatedGuard]
+ },
{ path: '**', pathMatch: 'full', component: PageNotFoundComponent },
],
{
diff --git a/src/app/core/auth/auth-request.service.ts b/src/app/core/auth/auth-request.service.ts
index 64cc5a9606..465fb69dd2 100644
--- a/src/app/core/auth/auth-request.service.ts
+++ b/src/app/core/auth/auth-request.service.ts
@@ -9,6 +9,7 @@ import { AuthGetRequest, AuthPostRequest, GetRequest, PostRequest, RestRequest }
import { AuthStatusResponse, ErrorResponse } from '../cache/response.models';
import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service';
import { getResponseFromEntry } from '../shared/operators';
+import { HttpClient } from '@angular/common/http';
@Injectable()
export class AuthRequestService {
@@ -16,7 +17,8 @@ export class AuthRequestService {
protected browseEndpoint = '';
constructor(protected halService: HALEndpointService,
- protected requestService: RequestService) {
+ protected requestService: RequestService,
+ private http: HttpClient) {
}
protected fetchRequest(request: RestRequest): Observable
{
@@ -36,7 +38,7 @@ export class AuthRequestService {
return isNotEmpty(method) ? `${endpoint}/${method}` : `${endpoint}`;
}
- public postToEndpoint(method: string, body: any, options?: HttpOptions): Observable {
+ public postToEndpoint(method: string, body?: any, options?: HttpOptions): Observable {
return this.halService.getEndpoint(this.linkName).pipe(
filter((href: string) => isNotEmpty(href)),
map((endpointURL) => this.getEndpointByMethod(endpointURL, method)),
diff --git a/src/app/core/auth/auth.actions.ts b/src/app/core/auth/auth.actions.ts
index 2681ed39a2..2c2224e878 100644
--- a/src/app/core/auth/auth.actions.ts
+++ b/src/app/core/auth/auth.actions.ts
@@ -5,6 +5,8 @@ import { type } from '../../shared/ngrx/type';
// import models
import { EPerson } from '../eperson/models/eperson.model';
import { AuthTokenInfo } from './models/auth-token-info.model';
+import { AuthMethod } from './models/auth.method';
+import { AuthStatus } from './models/auth-status.model';
export const AuthActionTypes = {
AUTHENTICATE: type('dspace/auth/AUTHENTICATE'),
@@ -14,12 +16,16 @@ export const AuthActionTypes = {
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'),
+ CHECK_AUTHENTICATION_TOKEN_COOKIE: type('dspace/auth/CHECK_AUTHENTICATION_TOKEN_COOKIE'),
+ RETRIEVE_AUTH_METHODS: type('dspace/auth/RETRIEVE_AUTH_METHODS'),
+ RETRIEVE_AUTH_METHODS_SUCCESS: type('dspace/auth/RETRIEVE_AUTH_METHODS_SUCCESS'),
+ RETRIEVE_AUTH_METHODS_ERROR: type('dspace/auth/RETRIEVE_AUTH_METHODS_ERROR'),
REDIRECT_TOKEN_EXPIRED: type('dspace/auth/REDIRECT_TOKEN_EXPIRED'),
REDIRECT_AUTHENTICATION_REQUIRED: type('dspace/auth/REDIRECT_AUTHENTICATION_REQUIRED'),
REFRESH_TOKEN: type('dspace/auth/REFRESH_TOKEN'),
REFRESH_TOKEN_SUCCESS: type('dspace/auth/REFRESH_TOKEN_SUCCESS'),
REFRESH_TOKEN_ERROR: type('dspace/auth/REFRESH_TOKEN_ERROR'),
+ RETRIEVE_TOKEN: type('dspace/auth/RETRIEVE_TOKEN'),
ADD_MESSAGE: type('dspace/auth/ADD_MESSAGE'),
RESET_MESSAGES: type('dspace/auth/RESET_MESSAGES'),
LOG_OUT: type('dspace/auth/LOG_OUT'),
@@ -95,7 +101,7 @@ export class AuthenticatedErrorAction implements Action {
payload: Error;
constructor(payload: Error) {
- this.payload = payload ;
+ this.payload = payload;
}
}
@@ -109,7 +115,7 @@ export class AuthenticationErrorAction implements Action {
payload: Error;
constructor(payload: Error) {
- this.payload = payload ;
+ this.payload = payload;
}
}
@@ -138,11 +144,11 @@ export class CheckAuthenticationTokenAction implements Action {
/**
* Check Authentication Token Error.
- * @class CheckAuthenticationTokenErrorAction
+ * @class CheckAuthenticationTokenCookieAction
* @implements {Action}
*/
-export class CheckAuthenticationTokenErrorAction implements Action {
- public type: string = AuthActionTypes.CHECK_AUTHENTICATION_TOKEN_ERROR;
+export class CheckAuthenticationTokenCookieAction implements Action {
+ public type: string = AuthActionTypes.CHECK_AUTHENTICATION_TOKEN_COOKIE;
}
/**
@@ -152,7 +158,9 @@ export class CheckAuthenticationTokenErrorAction implements Action {
*/
export class LogOutAction implements Action {
public type: string = AuthActionTypes.LOG_OUT;
- constructor(public payload?: any) {}
+
+ constructor(public payload?: any) {
+ }
}
/**
@@ -165,7 +173,7 @@ export class LogOutErrorAction implements Action {
payload: Error;
constructor(payload: Error) {
- this.payload = payload ;
+ this.payload = payload;
}
}
@@ -176,7 +184,9 @@ export class LogOutErrorAction implements Action {
*/
export class LogOutSuccessAction implements Action {
public type: string = AuthActionTypes.LOG_OUT_SUCCESS;
- constructor(public payload?: any) {}
+
+ constructor(public payload?: any) {
+ }
}
/**
@@ -189,7 +199,7 @@ export class RedirectWhenAuthenticationIsRequiredAction implements Action {
payload: string;
constructor(message: string) {
- this.payload = message ;
+ this.payload = message;
}
}
@@ -203,7 +213,7 @@ export class RedirectWhenTokenExpiredAction implements Action {
payload: string;
constructor(message: string) {
- this.payload = message ;
+ this.payload = message;
}
}
@@ -244,6 +254,15 @@ export class RefreshTokenErrorAction implements Action {
public type: string = AuthActionTypes.REFRESH_TOKEN_ERROR;
}
+/**
+ * Retrieve authentication token.
+ * @class RetrieveTokenAction
+ * @implements {Action}
+ */
+export class RetrieveTokenAction implements Action {
+ public type: string = AuthActionTypes.RETRIEVE_TOKEN;
+}
+
/**
* Sign up.
* @class RegistrationAction
@@ -268,7 +287,7 @@ export class RegistrationErrorAction implements Action {
payload: Error;
constructor(payload: Error) {
- this.payload = payload ;
+ this.payload = payload;
}
}
@@ -309,6 +328,45 @@ export class ResetAuthenticationMessagesAction implements Action {
public type: string = AuthActionTypes.RESET_MESSAGES;
}
+// // Next three Actions are used by dynamic login methods
+/**
+ * Action that triggers an effect fetching the authentication methods enabled ant the backend
+ * @class RetrieveAuthMethodsAction
+ * @implements {Action}
+ */
+export class RetrieveAuthMethodsAction implements Action {
+ public type: string = AuthActionTypes.RETRIEVE_AUTH_METHODS;
+
+ payload: AuthStatus;
+
+ constructor(authStatus: AuthStatus) {
+ this.payload = authStatus;
+ }
+}
+
+/**
+ * Get Authentication methods enabled at the backend
+ * @class RetrieveAuthMethodsSuccessAction
+ * @implements {Action}
+ */
+export class RetrieveAuthMethodsSuccessAction implements Action {
+ public type: string = AuthActionTypes.RETRIEVE_AUTH_METHODS_SUCCESS;
+ payload: AuthMethod[];
+
+ constructor(authMethods: AuthMethod[] ) {
+ this.payload = authMethods;
+ }
+}
+
+/**
+ * Set password as default authentication method on error
+ * @class RetrieveAuthMethodsErrorAction
+ * @implements {Action}
+ */
+export class RetrieveAuthMethodsErrorAction implements Action {
+ public type: string = AuthActionTypes.RETRIEVE_AUTH_METHODS_ERROR;
+}
+
/**
* Change the redirect url.
* @class SetRedirectUrlAction
@@ -319,7 +377,7 @@ export class SetRedirectUrlAction implements Action {
payload: string;
constructor(url: string) {
- this.payload = url ;
+ this.payload = url;
}
}
@@ -378,13 +436,21 @@ export type AuthActions
| AuthenticationErrorAction
| AuthenticationSuccessAction
| CheckAuthenticationTokenAction
- | CheckAuthenticationTokenErrorAction
+ | CheckAuthenticationTokenCookieAction
| RedirectWhenAuthenticationIsRequiredAction
| RedirectWhenTokenExpiredAction
| RegistrationAction
| RegistrationErrorAction
| RegistrationSuccessAction
| AddAuthenticationMessageAction
+ | RefreshTokenAction
+ | RefreshTokenErrorAction
+ | RefreshTokenSuccessAction
+ | ResetAuthenticationMessagesAction
+ | RetrieveAuthMethodsAction
+ | RetrieveAuthMethodsSuccessAction
+ | RetrieveAuthMethodsErrorAction
+ | RetrieveTokenAction
| ResetAuthenticationMessagesAction
| RetrieveAuthenticatedEpersonAction
| RetrieveAuthenticatedEpersonErrorAction
diff --git a/src/app/core/auth/auth.effects.spec.ts b/src/app/core/auth/auth.effects.spec.ts
index ca2fa2d08a..194a51e31e 100644
--- a/src/app/core/auth/auth.effects.spec.ts
+++ b/src/app/core/auth/auth.effects.spec.ts
@@ -14,20 +14,26 @@ import {
AuthenticatedSuccessAction,
AuthenticationErrorAction,
AuthenticationSuccessAction,
- CheckAuthenticationTokenErrorAction,
+ CheckAuthenticationTokenCookieAction,
LogOutErrorAction,
LogOutSuccessAction,
RefreshTokenErrorAction,
RefreshTokenSuccessAction,
RetrieveAuthenticatedEpersonAction,
RetrieveAuthenticatedEpersonErrorAction,
- RetrieveAuthenticatedEpersonSuccessAction
+ RetrieveAuthenticatedEpersonSuccessAction,
+ RetrieveAuthMethodsAction,
+ RetrieveAuthMethodsErrorAction,
+ RetrieveAuthMethodsSuccessAction,
+ RetrieveTokenAction
} from './auth.actions';
-import { AuthServiceStub } from '../../shared/testing/auth-service.stub';
+import { authMethodsMock, AuthServiceStub } from '../../shared/testing/auth-service.stub';
import { AuthService } from './auth.service';
import { AuthState } from './auth.reducer';
import { EPersonMock } from '../../shared/testing/eperson.mock';
+import { EPersonMock } from '../../shared/testing/eperson-mock';
+import { AuthStatus } from './models/auth-status.model';
describe('AuthEffects', () => {
let authEffects: AuthEffects;
@@ -169,13 +175,56 @@ describe('AuthEffects', () => {
actions = hot('--a-', { a: { type: AuthActionTypes.CHECK_AUTHENTICATION_TOKEN, payload: token } });
- const expected = cold('--b-', { b: new CheckAuthenticationTokenErrorAction() });
+ const expected = cold('--b-', { b: new CheckAuthenticationTokenCookieAction() });
expect(authEffects.checkToken$).toBeObservable(expected);
});
})
});
+ describe('checkTokenCookie$', () => {
+
+ describe('when check token succeeded', () => {
+ it('should return a RETRIEVE_TOKEN action in response to a CHECK_AUTHENTICATION_TOKEN_COOKIE action when authenticated is true', () => {
+ spyOn((authEffects as any).authService, 'checkAuthenticationCookie').and.returnValue(
+ observableOf(
+ {
+ authenticated: true
+ })
+ );
+ actions = hot('--a-', { a: { type: AuthActionTypes.CHECK_AUTHENTICATION_TOKEN_COOKIE } });
+
+ const expected = cold('--b-', { b: new RetrieveTokenAction() });
+
+ expect(authEffects.checkTokenCookie$).toBeObservable(expected);
+ });
+
+ it('should return a RETRIEVE_AUTH_METHODS action in response to a CHECK_AUTHENTICATION_TOKEN_COOKIE action when authenticated is false', () => {
+ spyOn((authEffects as any).authService, 'checkAuthenticationCookie').and.returnValue(
+ observableOf(
+ { authenticated: false })
+ );
+ actions = hot('--a-', { a: { type: AuthActionTypes.CHECK_AUTHENTICATION_TOKEN_COOKIE } });
+
+ const expected = cold('--b-', { b: new RetrieveAuthMethodsAction({ authenticated: false } as AuthStatus) });
+
+ expect(authEffects.checkTokenCookie$).toBeObservable(expected);
+ });
+ });
+
+ describe('when check token failed', () => {
+ it('should return a AUTHENTICATED_ERROR action in response to a CHECK_AUTHENTICATION_TOKEN_COOKIE action', () => {
+ spyOn((authEffects as any).authService, 'checkAuthenticationCookie').and.returnValue(observableThrow(new Error('Message Error test')));
+
+ actions = hot('--a-', { a: { type: AuthActionTypes.CHECK_AUTHENTICATION_TOKEN_COOKIE, payload: token } });
+
+ const expected = cold('--b-', { b: new AuthenticatedErrorAction(new Error('Message Error test')) });
+
+ expect(authEffects.checkTokenCookie$).toBeObservable(expected);
+ });
+ })
+ });
+
describe('retrieveAuthenticatedEperson$', () => {
describe('when request is successful', () => {
@@ -232,6 +281,38 @@ describe('AuthEffects', () => {
})
});
+ describe('retrieveToken$', () => {
+ describe('when user is authenticated', () => {
+ it('should return a AUTHENTICATE_SUCCESS action in response to a RETRIEVE_TOKEN action', () => {
+ actions = hot('--a-', {
+ a: {
+ type: AuthActionTypes.RETRIEVE_TOKEN
+ }
+ });
+
+ const expected = cold('--b-', { b: new AuthenticationSuccessAction(token) });
+
+ expect(authEffects.retrieveToken$).toBeObservable(expected);
+ });
+ });
+
+ describe('when user is not authenticated', () => {
+ it('should return a AUTHENTICATE_ERROR action in response to a RETRIEVE_TOKEN action', () => {
+ spyOn((authEffects as any).authService, 'refreshAuthenticationToken').and.returnValue(observableThrow(new Error('Message Error test')));
+
+ actions = hot('--a-', {
+ a: {
+ type: AuthActionTypes.RETRIEVE_TOKEN
+ }
+ });
+
+ const expected = cold('--b-', { b: new AuthenticationErrorAction(new Error('Message Error test')) });
+
+ expect(authEffects.retrieveToken$).toBeObservable(expected);
+ });
+ });
+ });
+
describe('logOut$', () => {
describe('when refresh token succeeded', () => {
@@ -257,4 +338,29 @@ describe('AuthEffects', () => {
});
})
});
+
+ describe('retrieveMethods$', () => {
+
+ describe('when retrieve authentication methods succeeded', () => {
+ it('should return a RETRIEVE_AUTH_METHODS_SUCCESS action in response to a RETRIEVE_AUTH_METHODS action', () => {
+ actions = hot('--a-', { a: { type: AuthActionTypes.RETRIEVE_AUTH_METHODS } });
+
+ const expected = cold('--b-', { b: new RetrieveAuthMethodsSuccessAction(authMethodsMock) });
+
+ expect(authEffects.retrieveMethods$).toBeObservable(expected);
+ });
+ });
+
+ describe('when retrieve authentication methods failed', () => {
+ it('should return a RETRIEVE_AUTH_METHODS_ERROR action in response to a RETRIEVE_AUTH_METHODS action', () => {
+ spyOn((authEffects as any).authService, 'retrieveAuthMethodsFromAuthStatus').and.returnValue(observableThrow(''));
+
+ actions = hot('--a-', { a: { type: AuthActionTypes.RETRIEVE_AUTH_METHODS } });
+
+ const expected = cold('--b-', { b: new RetrieveAuthMethodsErrorAction() });
+
+ expect(authEffects.retrieveMethods$).toBeObservable(expected);
+ });
+ })
+ });
});
diff --git a/src/app/core/auth/auth.effects.ts b/src/app/core/auth/auth.effects.ts
index 5ee63ccd92..d153748fb9 100644
--- a/src/app/core/auth/auth.effects.ts
+++ b/src/app/core/auth/auth.effects.ts
@@ -1,6 +1,6 @@
-import { of as observableOf, Observable } from 'rxjs';
+import { Observable, of as observableOf } from 'rxjs';
-import { filter, debounceTime, switchMap, take, tap, catchError, map } from 'rxjs/operators';
+import { catchError, debounceTime, filter, map, switchMap, take, tap } from 'rxjs/operators';
import { Injectable } from '@angular/core';
// import @ngrx
@@ -9,6 +9,14 @@ import { Action, select, Store } from '@ngrx/store';
// import services
import { AuthService } from './auth.service';
+
+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';
+import { AuthMethod } from './models/auth.method';
// import actions
import {
AuthActionTypes,
@@ -18,7 +26,7 @@ import {
AuthenticatedSuccessAction,
AuthenticationErrorAction,
AuthenticationSuccessAction,
- CheckAuthenticationTokenErrorAction,
+ CheckAuthenticationTokenCookieAction,
LogOutErrorAction,
LogOutSuccessAction,
RefreshTokenAction,
@@ -29,14 +37,12 @@ import {
RegistrationSuccessAction,
RetrieveAuthenticatedEpersonAction,
RetrieveAuthenticatedEpersonErrorAction,
- RetrieveAuthenticatedEpersonSuccessAction
+ RetrieveAuthenticatedEpersonSuccessAction,
+ RetrieveAuthMethodsAction,
+ RetrieveAuthMethodsErrorAction,
+ RetrieveAuthMethodsSuccessAction,
+ RetrieveTokenAction
} 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 {
@@ -47,45 +53,45 @@ export class AuthEffects {
*/
@Effect()
public authenticate$: Observable = this.actions$.pipe(
- ofType(AuthActionTypes.AUTHENTICATE),
- switchMap((action: AuthenticateAction) => {
- return this.authService.authenticate(action.payload.email, action.payload.password).pipe(
- take(1),
- map((response: AuthStatus) => new AuthenticationSuccessAction(response.token)),
- catchError((error) => observableOf(new AuthenticationErrorAction(error)))
- );
- })
- );
+ ofType(AuthActionTypes.AUTHENTICATE),
+ switchMap((action: AuthenticateAction) => {
+ return this.authService.authenticate(action.payload.email, action.payload.password).pipe(
+ take(1),
+ map((response: AuthStatus) => new AuthenticationSuccessAction(response.token)),
+ catchError((error) => observableOf(new AuthenticationErrorAction(error)))
+ );
+ })
+ );
@Effect()
public authenticateSuccess$: Observable = this.actions$.pipe(
- ofType(AuthActionTypes.AUTHENTICATE_SUCCESS),
- tap((action: AuthenticationSuccessAction) => this.authService.storeToken(action.payload)),
- map((action: AuthenticationSuccessAction) => new AuthenticatedAction(action.payload))
- );
+ ofType(AuthActionTypes.AUTHENTICATE_SUCCESS),
+ tap((action: AuthenticationSuccessAction) => this.authService.storeToken(action.payload)),
+ map((action: AuthenticationSuccessAction) => new AuthenticatedAction(action.payload))
+ );
@Effect()
public authenticated$: Observable = this.actions$.pipe(
- ofType(AuthActionTypes.AUTHENTICATED),
- switchMap((action: AuthenticatedAction) => {
- return this.authService.authenticatedUser(action.payload).pipe(
- map((userHref: string) => new AuthenticatedSuccessAction((userHref !== null), action.payload, userHref)),
- catchError((error) => observableOf(new AuthenticatedErrorAction(error))),);
- })
- );
+ ofType(AuthActionTypes.AUTHENTICATED),
+ switchMap((action: AuthenticatedAction) => {
+ return this.authService.authenticatedUser(action.payload).pipe(
+ map((userHref: string) => new AuthenticatedSuccessAction((userHref !== null), action.payload, userHref)),
+ catchError((error) => observableOf(new AuthenticatedErrorAction(error))),);
+ })
+ );
@Effect()
public authenticatedSuccess$: Observable = this.actions$.pipe(
- ofType(AuthActionTypes.AUTHENTICATED_SUCCESS),
- map((action: AuthenticatedSuccessAction) => new RetrieveAuthenticatedEpersonAction(action.payload.userHref))
- );
+ ofType(AuthActionTypes.AUTHENTICATED_SUCCESS),
+ map((action: AuthenticatedSuccessAction) => new RetrieveAuthenticatedEpersonAction(action.payload.userHref))
+ );
// It means "reacts to this action but don't send another"
@Effect({ dispatch: false })
public authenticatedError$: Observable = this.actions$.pipe(
- ofType(AuthActionTypes.AUTHENTICATED_ERROR),
- tap((action: LogOutSuccessAction) => this.authService.removeToken())
- );
+ ofType(AuthActionTypes.AUTHENTICATED_ERROR),
+ tap((action: LogOutSuccessAction) => this.authService.removeToken())
+ );
@Effect()
public retrieveAuthenticatedEperson$: Observable = this.actions$.pipe(
@@ -99,42 +105,71 @@ export class AuthEffects {
@Effect()
public checkToken$: Observable = this.actions$.pipe(ofType(AuthActionTypes.CHECK_AUTHENTICATION_TOKEN),
- switchMap(() => {
- return this.authService.hasValidAuthenticationToken().pipe(
- map((token: AuthTokenInfo) => new AuthenticatedAction(token)),
- catchError((error) => observableOf(new CheckAuthenticationTokenErrorAction()))
- );
- })
- );
+ switchMap(() => {
+ return this.authService.hasValidAuthenticationToken().pipe(
+ map((token: AuthTokenInfo) => new AuthenticatedAction(token)),
+ catchError((error) => observableOf(new CheckAuthenticationTokenCookieAction()))
+ );
+ })
+ );
+
+ @Effect()
+ public checkTokenCookie$: Observable = this.actions$.pipe(
+ ofType(AuthActionTypes.CHECK_AUTHENTICATION_TOKEN_COOKIE),
+ switchMap(() => {
+ return this.authService.checkAuthenticationCookie().pipe(
+ map((response: AuthStatus) => {
+ if (response.authenticated) {
+ return new RetrieveTokenAction();
+ } else {
+ return new RetrieveAuthMethodsAction(response);
+ }
+ }),
+ catchError((error) => observableOf(new AuthenticatedErrorAction(error)))
+ );
+ })
+ );
@Effect()
public createUser$: Observable = this.actions$.pipe(
- ofType(AuthActionTypes.REGISTRATION),
- debounceTime(500), // to remove when functionality is implemented
- switchMap((action: RegistrationAction) => {
- return this.authService.create(action.payload).pipe(
- map((user: EPerson) => new RegistrationSuccessAction(user)),
- catchError((error) => observableOf(new RegistrationErrorAction(error)))
- );
- })
- );
+ ofType(AuthActionTypes.REGISTRATION),
+ debounceTime(500), // to remove when functionality is implemented
+ switchMap((action: RegistrationAction) => {
+ return this.authService.create(action.payload).pipe(
+ map((user: EPerson) => new RegistrationSuccessAction(user)),
+ catchError((error) => observableOf(new RegistrationErrorAction(error)))
+ );
+ })
+ );
+
+ @Effect()
+ public retrieveToken$: Observable = this.actions$.pipe(
+ ofType(AuthActionTypes.RETRIEVE_TOKEN),
+ switchMap((action: AuthenticateAction) => {
+ return this.authService.refreshAuthenticationToken(null).pipe(
+ take(1),
+ map((token: AuthTokenInfo) => new AuthenticationSuccessAction(token)),
+ catchError((error) => observableOf(new AuthenticationErrorAction(error)))
+ );
+ })
+ );
@Effect()
public refreshToken$: Observable = this.actions$.pipe(ofType(AuthActionTypes.REFRESH_TOKEN),
- switchMap((action: RefreshTokenAction) => {
- return this.authService.refreshAuthenticationToken(action.payload).pipe(
- map((token: AuthTokenInfo) => new RefreshTokenSuccessAction(token)),
- catchError((error) => observableOf(new RefreshTokenErrorAction()))
- );
- })
- );
+ switchMap((action: RefreshTokenAction) => {
+ return this.authService.refreshAuthenticationToken(action.payload).pipe(
+ map((token: AuthTokenInfo) => new RefreshTokenSuccessAction(token)),
+ catchError((error) => observableOf(new RefreshTokenErrorAction()))
+ );
+ })
+ );
// It means "reacts to this action but don't send another"
@Effect({ dispatch: false })
public refreshTokenSuccess$: Observable = this.actions$.pipe(
- ofType(AuthActionTypes.REFRESH_TOKEN_SUCCESS),
- tap((action: RefreshTokenSuccessAction) => this.authService.replaceToken(action.payload))
- );
+ ofType(AuthActionTypes.REFRESH_TOKEN_SUCCESS),
+ tap((action: RefreshTokenSuccessAction) => this.authService.replaceToken(action.payload))
+ );
/**
* When the store is rehydrated in the browser,
@@ -188,6 +223,19 @@ export class AuthEffects {
tap(() => this.authService.redirectToLoginWhenTokenExpired())
);
+ @Effect()
+ public retrieveMethods$: Observable = this.actions$
+ .pipe(
+ ofType(AuthActionTypes.RETRIEVE_AUTH_METHODS),
+ switchMap((action: RetrieveAuthMethodsAction) => {
+ return this.authService.retrieveAuthMethodsFromAuthStatus(action.payload)
+ .pipe(
+ map((authMethodModels: AuthMethod[]) => new RetrieveAuthMethodsSuccessAction(authMethodModels)),
+ catchError((error) => observableOf(new RetrieveAuthMethodsErrorAction()))
+ )
+ })
+ );
+
/**
* @constructor
* @param {Actions} actions$
diff --git a/src/app/core/auth/auth.interceptor.ts b/src/app/core/auth/auth.interceptor.ts
index 08e892bbd9..6d609a4ea3 100644
--- a/src/app/core/auth/auth.interceptor.ts
+++ b/src/app/core/auth/auth.interceptor.ts
@@ -6,6 +6,7 @@ import {
HttpErrorResponse,
HttpEvent,
HttpHandler,
+ HttpHeaders,
HttpInterceptor,
HttpRequest,
HttpResponse,
@@ -17,10 +18,12 @@ import { AppState } from '../../app.reducer';
import { AuthService } from './auth.service';
import { AuthStatus } from './models/auth-status.model';
import { AuthTokenInfo } from './models/auth-token-info.model';
-import { isNotEmpty, isUndefined, isNotNull } from '../../shared/empty.util';
+import { isNotEmpty, isNotNull, isUndefined } from '../../shared/empty.util';
import { RedirectWhenTokenExpiredAction, RefreshTokenAction } from './auth.actions';
import { Store } from '@ngrx/store';
import { Router } from '@angular/router';
+import { AuthMethod } from './models/auth.method';
+import { AuthMethodType } from './models/auth.method-type';
@Injectable()
export class AuthInterceptor implements HttpInterceptor {
@@ -30,17 +33,33 @@ export class AuthInterceptor implements HttpInterceptor {
// we're creating a refresh token request list
protected refreshTokenRequestUrls = [];
- constructor(private inj: Injector, private router: Router, private store: Store) { }
+ constructor(private inj: Injector, private router: Router, private store: Store) {
+ }
+ /**
+ * Check if response status code is 401
+ *
+ * @param response
+ */
private isUnauthorized(response: HttpResponseBase): boolean {
// invalid_token The access token provided is expired, revoked, malformed, or invalid for other reasons
return response.status === 401;
}
+ /**
+ * Check if response status code is 200 or 204
+ *
+ * @param response
+ */
private isSuccess(response: HttpResponseBase): boolean {
return (response.status === 200 || response.status === 204);
}
+ /**
+ * Check if http request is to authn endpoint
+ *
+ * @param http
+ */
private isAuthRequest(http: HttpRequest | HttpResponseBase): boolean {
return http && http.url
&& (http.url.endsWith('/authn/login')
@@ -48,18 +67,131 @@ export class AuthInterceptor implements HttpInterceptor {
|| http.url.endsWith('/authn/status'));
}
+ /**
+ * Check if response is from a login request
+ *
+ * @param http
+ */
private isLoginResponse(http: HttpRequest | HttpResponseBase): boolean {
- return http.url && http.url.endsWith('/authn/login');
+ return http.url && http.url.endsWith('/authn/login')
}
+ /**
+ * Check if response is from a logout request
+ *
+ * @param http
+ */
private isLogoutResponse(http: HttpRequest | HttpResponseBase): boolean {
return http.url && http.url.endsWith('/authn/logout');
}
- private makeAuthStatusObject(authenticated: boolean, accessToken?: string, error?: string): AuthStatus {
+ /**
+ * Check if response is from a status request
+ *
+ * @param http
+ */
+ private isStatusResponse(http: HttpRequest | HttpResponseBase): boolean {
+ return http.url && http.url.endsWith('/authn/status');
+ }
+
+ /**
+ * Extract location url from the WWW-Authenticate header
+ *
+ * @param header
+ */
+ private parseLocation(header: string): string {
+ let location = header.trim();
+ location = location.replace('location="', '');
+ location = location.replace('"', '');
+ let re = /%3A%2F%2F/g;
+ location = location.replace(re, '://');
+ re = /%3A/g;
+ location = location.replace(re, ':');
+ return location.trim();
+ }
+
+ /**
+ * Sort authentication methods list
+ *
+ * @param authMethodModels
+ */
+ private sortAuthMethods(authMethodModels: AuthMethod[]): AuthMethod[] {
+ const sortedAuthMethodModels: AuthMethod[] = [];
+ authMethodModels.forEach((method) => {
+ if (method.authMethodType === AuthMethodType.Password) {
+ sortedAuthMethodModels.push(method);
+ }
+ });
+
+ authMethodModels.forEach((method) => {
+ if (method.authMethodType !== AuthMethodType.Password) {
+ sortedAuthMethodModels.push(method);
+ }
+ });
+
+ return sortedAuthMethodModels;
+ }
+
+ /**
+ * Extract authentication methods list from the WWW-Authenticate headers
+ *
+ * @param headers
+ */
+ private parseAuthMethodsFromHeaders(headers: HttpHeaders): AuthMethod[] {
+ let authMethodModels: AuthMethod[] = [];
+ if (isNotEmpty(headers.get('www-authenticate'))) {
+ // get the realms from the header - a realm is a single auth method
+ const completeWWWauthenticateHeader = headers.get('www-authenticate');
+ const regex = /(\w+ (\w+=((".*?")|[^,]*)(, )?)*)/g;
+ const realms = completeWWWauthenticateHeader.match(regex);
+
+ // tslint:disable-next-line:forin
+ for (const j in realms) {
+
+ const splittedRealm = realms[j].split(', ');
+ const methodName = splittedRealm[0].split(' ')[0].trim();
+
+ let authMethodModel: AuthMethod;
+ if (splittedRealm.length === 1) {
+ authMethodModel = new AuthMethod(methodName);
+ authMethodModels.push(authMethodModel);
+ } else if (splittedRealm.length > 1) {
+ let location = splittedRealm[1];
+ location = this.parseLocation(location);
+ authMethodModel = new AuthMethod(methodName, location);
+ authMethodModels.push(authMethodModel);
+ }
+ }
+
+ // make sure the email + password login component gets rendered first
+ authMethodModels = this.sortAuthMethods(authMethodModels);
+ } else {
+ authMethodModels.push(new AuthMethod(AuthMethodType.Password));
+ }
+
+ return authMethodModels;
+ }
+
+ /**
+ * Generate an AuthStatus object
+ *
+ * @param authenticated
+ * @param accessToken
+ * @param error
+ * @param httpHeaders
+ */
+ private makeAuthStatusObject(authenticated: boolean, accessToken?: string, error?: string, httpHeaders?: HttpHeaders): AuthStatus {
const authStatus = new AuthStatus();
+ // let authMethods: AuthMethodModel[];
+ if (httpHeaders) {
+ authStatus.authMethods = this.parseAuthMethodsFromHeaders(httpHeaders);
+ }
+
authStatus.id = null;
+
authStatus.okay = true;
+ // authStatus.authMethods = authMethods;
+
if (authenticated) {
authStatus.authenticated = true;
authStatus.token = new AuthTokenInfo(accessToken);
@@ -70,12 +202,18 @@ export class AuthInterceptor implements HttpInterceptor {
return authStatus;
}
+ /**
+ * Intercept method
+ * @param req
+ * @param next
+ */
intercept(req: HttpRequest, next: HttpHandler): Observable> {
const authService = this.inj.get(AuthService);
- const token = authService.getToken();
- let newReq;
+ const token: AuthTokenInfo = authService.getToken();
+ let newReq: HttpRequest;
+ let authorization: string;
if (authService.isTokenExpired()) {
authService.setRedirectUrl(this.router.url);
@@ -96,30 +234,41 @@ export class AuthInterceptor implements HttpInterceptor {
}
});
// Get the auth header from the service.
- const Authorization = authService.buildAuthHeader(token);
+ authorization = authService.buildAuthHeader(token);
// Clone the request to add the new header.
- newReq = req.clone({headers: req.headers.set('authorization', Authorization)});
+ newReq = req.clone({ headers: req.headers.set('authorization', authorization) });
} else {
- newReq = req;
+ newReq = req.clone();
}
// Pass on the new request instead of the original request.
return next.handle(newReq).pipe(
+ // tap((response) => console.log('next.handle: ', response)),
map((response) => {
// Intercept a Login/Logout response
- if (response instanceof HttpResponse && this.isSuccess(response) && (this.isLoginResponse(response) || this.isLogoutResponse(response))) {
+ if (response instanceof HttpResponse && this.isSuccess(response) && this.isAuthRequest(response)) {
// It's a success Login/Logout response
let authRes: HttpResponse;
if (this.isLoginResponse(response)) {
// login successfully
const newToken = response.headers.get('authorization');
- authRes = response.clone({body: this.makeAuthStatusObject(true, newToken)});
+ authRes = response.clone({
+ body: this.makeAuthStatusObject(true, newToken)
+ });
// clean eventually refresh Requests list
this.refreshTokenRequestUrls = [];
+ } else if (this.isStatusResponse(response)) {
+ authRes = response.clone({
+ body: Object.assign(response.body, {
+ authMethods: this.parseAuthMethodsFromHeaders(response.headers)
+ })
+ })
} else {
// logout successfully
- authRes = response.clone({body: this.makeAuthStatusObject(false)});
+ authRes = response.clone({
+ body: this.makeAuthStatusObject(false)
+ });
}
return authRes;
} else {
@@ -129,13 +278,15 @@ export class AuthInterceptor implements HttpInterceptor {
catchError((error, caught) => {
// Intercept an error response
if (error instanceof HttpErrorResponse) {
+
// Checks if is a response from a request to an authentication endpoint
if (this.isAuthRequest(error)) {
// clean eventually refresh Requests list
this.refreshTokenRequestUrls = [];
+
// Create a new HttpResponse and return it, so it can be handle properly by AuthService.
const authResponse = new HttpResponse({
- body: this.makeAuthStatusObject(false, null, error.error),
+ body: this.makeAuthStatusObject(false, null, error.error, error.headers),
headers: error.headers,
status: error.status,
statusText: error.statusText,
diff --git a/src/app/core/auth/auth.reducer.spec.ts b/src/app/core/auth/auth.reducer.spec.ts
index af1b6626d0..7a39ef3da4 100644
--- a/src/app/core/auth/auth.reducer.spec.ts
+++ b/src/app/core/auth/auth.reducer.spec.ts
@@ -8,7 +8,7 @@ import {
AuthenticationErrorAction,
AuthenticationSuccessAction,
CheckAuthenticationTokenAction,
- CheckAuthenticationTokenErrorAction,
+ CheckAuthenticationTokenCookieAction,
LogOutAction,
LogOutErrorAction,
LogOutSuccessAction,
@@ -17,11 +17,19 @@ import {
RefreshTokenAction,
RefreshTokenErrorAction,
RefreshTokenSuccessAction,
- ResetAuthenticationMessagesAction, RetrieveAuthenticatedEpersonErrorAction, RetrieveAuthenticatedEpersonSuccessAction,
+ ResetAuthenticationMessagesAction,
+ RetrieveAuthenticatedEpersonErrorAction,
+ RetrieveAuthenticatedEpersonSuccessAction,
+ RetrieveAuthMethodsAction,
+ RetrieveAuthMethodsErrorAction,
+ RetrieveAuthMethodsSuccessAction,
SetRedirectUrlAction
} from './auth.actions';
import { AuthTokenInfo } from './models/auth-token-info.model';
-import { EPersonMock } from '../../shared/testing/eperson.mock';
+import { EPersonMock } from '../../shared/testing/eperson-mock';
+import { AuthStatus } from './models/auth-status.model';
+import { AuthMethod } from './models/auth.method';
+import { AuthMethodType } from './models/auth.method-type';
describe('authReducer', () => {
@@ -157,18 +165,18 @@ describe('authReducer', () => {
expect(newState).toEqual(state);
});
- it('should properly set the state, in response to a CHECK_AUTHENTICATION_TOKEN_ERROR action', () => {
+ it('should properly set the state, in response to a CHECK_AUTHENTICATION_TOKEN_COOKIE action', () => {
initialState = {
authenticated: false,
loaded: false,
loading: true,
};
- const action = new CheckAuthenticationTokenErrorAction();
+ const action = new CheckAuthenticationTokenCookieAction();
const newState = authReducer(initialState, action);
state = {
authenticated: false,
loaded: false,
- loading: false,
+ loading: true,
};
expect(newState).toEqual(state);
});
@@ -451,4 +459,63 @@ describe('authReducer', () => {
};
expect(newState).toEqual(state);
});
+
+ it('should properly set the state, in response to a RETRIEVE_AUTH_METHODS action', () => {
+ initialState = {
+ authenticated: false,
+ loaded: false,
+ loading: false,
+ authMethods: []
+ };
+ const action = new RetrieveAuthMethodsAction(new AuthStatus());
+ const newState = authReducer(initialState, action);
+ state = {
+ authenticated: false,
+ loaded: false,
+ loading: true,
+ authMethods: []
+ };
+ expect(newState).toEqual(state);
+ });
+
+ it('should properly set the state, in response to a RETRIEVE_AUTH_METHODS_SUCCESS action', () => {
+ initialState = {
+ authenticated: false,
+ loaded: false,
+ loading: true,
+ authMethods: []
+ };
+ const authMethods = [
+ new AuthMethod(AuthMethodType.Password),
+ new AuthMethod(AuthMethodType.Shibboleth, 'location')
+ ];
+ const action = new RetrieveAuthMethodsSuccessAction(authMethods);
+ const newState = authReducer(initialState, action);
+ state = {
+ authenticated: false,
+ loaded: false,
+ loading: false,
+ authMethods: authMethods
+ };
+ expect(newState).toEqual(state);
+ });
+
+ it('should properly set the state, in response to a RETRIEVE_AUTH_METHODS_ERROR action', () => {
+ initialState = {
+ authenticated: false,
+ loaded: false,
+ loading: true,
+ authMethods: []
+ };
+
+ const action = new RetrieveAuthMethodsErrorAction();
+ const newState = authReducer(initialState, action);
+ state = {
+ authenticated: false,
+ loaded: false,
+ loading: false,
+ authMethods: [new AuthMethod(AuthMethodType.Password)]
+ };
+ expect(newState).toEqual(state);
+ });
});
diff --git a/src/app/core/auth/auth.reducer.ts b/src/app/core/auth/auth.reducer.ts
index 7d5e50c432..19fd162d3f 100644
--- a/src/app/core/auth/auth.reducer.ts
+++ b/src/app/core/auth/auth.reducer.ts
@@ -8,12 +8,16 @@ import {
LogOutErrorAction,
RedirectWhenAuthenticationIsRequiredAction,
RedirectWhenTokenExpiredAction,
- RefreshTokenSuccessAction, RetrieveAuthenticatedEpersonSuccessAction,
+ RefreshTokenSuccessAction,
+ RetrieveAuthenticatedEpersonSuccessAction,
+ RetrieveAuthMethodsSuccessAction,
SetRedirectUrlAction
} from './auth.actions';
// import models
import { EPerson } from '../eperson/models/eperson.model';
import { AuthTokenInfo } from './models/auth-token-info.model';
+import { AuthMethod } from './models/auth.method';
+import { AuthMethodType } from './models/auth.method-type';
/**
* The auth state.
@@ -47,6 +51,10 @@ export interface AuthState {
// the authenticated user
user?: EPerson;
+
+ // all authentication Methods enabled at the backend
+ authMethods?: AuthMethod[];
+
}
/**
@@ -56,6 +64,7 @@ const initialState: AuthState = {
authenticated: false,
loaded: false,
loading: false,
+ authMethods: []
};
/**
@@ -75,6 +84,8 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut
});
case AuthActionTypes.AUTHENTICATED:
+ case AuthActionTypes.CHECK_AUTHENTICATION_TOKEN:
+ case AuthActionTypes.CHECK_AUTHENTICATION_TOKEN_COOKIE:
return Object.assign({}, state, {
loading: true
});
@@ -113,21 +124,10 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut
loading: false
});
- case AuthActionTypes.AUTHENTICATED:
case AuthActionTypes.AUTHENTICATE_SUCCESS:
case AuthActionTypes.LOG_OUT:
return state;
- case AuthActionTypes.CHECK_AUTHENTICATION_TOKEN:
- return Object.assign({}, state, {
- loading: true
- });
-
- case AuthActionTypes.CHECK_AUTHENTICATION_TOKEN_ERROR:
- return Object.assign({}, state, {
- loading: false
- });
-
case AuthActionTypes.LOG_OUT_ERROR:
return Object.assign({}, state, {
authenticated: true,
@@ -192,6 +192,24 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut
info: undefined,
});
+ // next three cases are used by dynamic rendering of login methods
+ case AuthActionTypes.RETRIEVE_AUTH_METHODS:
+ return Object.assign({}, state, {
+ loading: true
+ });
+
+ case AuthActionTypes.RETRIEVE_AUTH_METHODS_SUCCESS:
+ return Object.assign({}, state, {
+ loading: false,
+ authMethods: (action as RetrieveAuthMethodsSuccessAction).payload
+ });
+
+ case AuthActionTypes.RETRIEVE_AUTH_METHODS_ERROR:
+ return Object.assign({}, state, {
+ loading: false,
+ authMethods: [new AuthMethod(AuthMethodType.Password)]
+ });
+
case AuthActionTypes.SET_REDIRECT_URL:
return Object.assign({}, state, {
redirectUrl: (action as SetRedirectUrlAction).payload,
diff --git a/src/app/core/auth/auth.service.spec.ts b/src/app/core/auth/auth.service.spec.ts
index d29902eedb..89d8cdce4e 100644
--- a/src/app/core/auth/auth.service.spec.ts
+++ b/src/app/core/auth/auth.service.spec.ts
@@ -28,6 +28,8 @@ import { Observable } from 'rxjs/internal/Observable';
import { RemoteData } from '../data/remote-data';
import { EPersonDataService } from '../eperson/eperson-data.service';
import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
+import { authMethodsMock } from '../../shared/testing/auth-service.stub';
+import { AuthMethod } from './models/auth.method';
describe('AuthService test', () => {
@@ -150,6 +152,26 @@ describe('AuthService test', () => {
expect(authService.logout.bind(null)).toThrow();
});
+ it('should return the authentication status object to check an Authentication Cookie', () => {
+ authService.checkAuthenticationCookie().subscribe((status: AuthStatus) => {
+ expect(status).toBeDefined();
+ });
+ });
+
+ it('should return the authentication methods available', () => {
+ const authStatus = new AuthStatus();
+
+ authService.retrieveAuthMethodsFromAuthStatus(authStatus).subscribe((authMethods: AuthMethod[]) => {
+ expect(authMethods).toBeDefined();
+ expect(authMethods.length).toBe(0);
+ });
+
+ authStatus.authMethods = authMethodsMock;
+ authService.retrieveAuthMethodsFromAuthStatus(authStatus).subscribe((authMethods: AuthMethod[]) => {
+ expect(authMethods).toBeDefined();
+ expect(authMethods.length).toBe(2);
+ });
+ });
});
describe('', () => {
diff --git a/src/app/core/auth/auth.service.ts b/src/app/core/auth/auth.service.ts
index f8847b0b2e..0f5c06bbc9 100644
--- a/src/app/core/auth/auth.service.ts
+++ b/src/app/core/auth/auth.service.ts
@@ -18,16 +18,20 @@ import { isEmpty, isNotEmpty, isNotNull, isNotUndefined } from '../../shared/emp
import { CookieService } from '../services/cookie.service';
import { getAuthenticationToken, getRedirectUrl, isAuthenticated, isTokenRefreshing } from './selectors';
import { AppState, routerStateSelector } from '../../app.reducer';
-import { ResetAuthenticationMessagesAction, SetRedirectUrlAction } from './auth.actions';
+import {
+ CheckAuthenticationTokenAction,
+ ResetAuthenticationMessagesAction,
+ SetRedirectUrlAction
+} from './auth.actions';
import { NativeWindowRef, NativeWindowService } from '../services/window.service';
import { Base64EncodeUrl } from '../../shared/utils/encode-decode.util';
import { RouteService } from '../services/route.service';
import { EPersonDataService } from '../eperson/eperson-data.service';
-import { getAllSucceededRemoteDataPayload, getFirstSucceededRemoteDataPayload } from '../shared/operators';
+import { getAllSucceededRemoteDataPayload } from '../shared/operators';
+import { AuthMethod } from './models/auth.method';
export const LOGIN_ROUTE = '/login';
export const LOGOUT_ROUTE = '/logout';
-
export const REDIRECT_COOKIE = 'dsRedirectUrl';
/**
@@ -114,6 +118,21 @@ export class AuthService {
}
+ /**
+ * Checks if token is present into the request cookie
+ */
+ public checkAuthenticationCookie(): Observable {
+ // Determine if the user has an existing auth session on the server
+ const options: HttpOptions = Object.create({});
+ let headers = new HttpHeaders();
+ headers = headers.append('Accept', 'application/json');
+ options.headers = headers;
+ options.withCredentials = true;
+ return this.authRequestService.getRequest('status', options).pipe(
+ map((status: AuthStatus) => Object.assign(new AuthStatus(), status))
+ );
+ }
+
/**
* Determines if the user is authenticated
* @returns {Observable}
@@ -154,10 +173,10 @@ export class AuthService {
}
/**
- * Checks if token is present into browser storage and is valid. (NB Check is done only on SSR)
+ * Checks if token is present into browser storage and is valid.
*/
public checkAuthenticationToken() {
- return
+ this.store.dispatch(new CheckAuthenticationTokenAction());
}
/**
@@ -187,8 +206,11 @@ export class AuthService {
const options: HttpOptions = Object.create({});
let headers = new HttpHeaders();
headers = headers.append('Accept', 'application/json');
- headers = headers.append('Authorization', `Bearer ${token.accessToken}`);
+ if (token && token.accessToken) {
+ headers = headers.append('Authorization', `Bearer ${token.accessToken}`);
+ }
options.headers = headers;
+ options.withCredentials = true;
return this.authRequestService.postToEndpoint('login', {}, options).pipe(
map((status: AuthStatus) => {
if (status.authenticated) {
@@ -206,6 +228,18 @@ export class AuthService {
this.store.dispatch(new ResetAuthenticationMessagesAction());
}
+ /**
+ * Retrieve authentication methods available
+ * @returns {User}
+ */
+ public retrieveAuthMethodsFromAuthStatus(status: AuthStatus): Observable {
+ let authMethods: AuthMethod[] = [];
+ if (isNotEmpty(status.authMethods)) {
+ authMethods = status.authMethods;
+ }
+ return observableOf(authMethods);
+ }
+
/**
* Create a new user
* @returns {User}
diff --git a/src/app/core/auth/authenticated.guard.ts b/src/app/core/auth/authenticated.guard.ts
index af0622cd19..7a2f39854c 100644
--- a/src/app/core/auth/authenticated.guard.ts
+++ b/src/app/core/auth/authenticated.guard.ts
@@ -1,17 +1,14 @@
-
-import {take} from 'rxjs/operators';
import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, CanActivate, CanLoad, Route, Router, RouterStateSnapshot } from '@angular/router';
-import {Observable, of} from 'rxjs';
+import { Observable } from 'rxjs';
+import { take } from 'rxjs/operators';
import { select, Store } from '@ngrx/store';
-// reducers
import { CoreState } from '../core.reducers';
-import { isAuthenticated, isAuthenticationLoading } from './selectors';
+import { isAuthenticated } from './selectors';
import { AuthService } from './auth.service';
import { RedirectWhenAuthenticationIsRequiredAction } from './auth.actions';
-import { isEmpty } from '../../shared/empty.util';
/**
* Prevent unauthorized activating and loading of routes
diff --git a/src/app/core/auth/models/auth-status.model.ts b/src/app/core/auth/models/auth-status.model.ts
index edad46a7bc..197c025407 100644
--- a/src/app/core/auth/models/auth-status.model.ts
+++ b/src/app/core/auth/models/auth-status.model.ts
@@ -12,6 +12,7 @@ import { excludeFromEquals } from '../../utilities/equals.decorators';
import { AuthError } from './auth-error.model';
import { AUTH_STATUS } from './auth-status.resource-type';
import { AuthTokenInfo } from './auth-token-info.model';
+import { AuthMethod } from './auth.method';
/**
* Object that represents the authenticated status of a user
@@ -79,5 +80,13 @@ export class AuthStatus implements CacheableObject {
* Authentication error if there was one for this status
*/
// TODO should be refactored to use the RemoteData error
+ @autoserialize
error?: AuthError;
+
+ /**
+ * All authentication methods enabled at the backend
+ */
+ @autoserialize
+ authMethods: AuthMethod[];
+
}
diff --git a/src/app/core/auth/models/auth.method-type.ts b/src/app/core/auth/models/auth.method-type.ts
new file mode 100644
index 0000000000..f053515065
--- /dev/null
+++ b/src/app/core/auth/models/auth.method-type.ts
@@ -0,0 +1,7 @@
+export enum AuthMethodType {
+ Password = 'password',
+ Shibboleth = 'shibboleth',
+ Ldap = 'ldap',
+ Ip = 'ip',
+ X509 = 'x509'
+}
diff --git a/src/app/core/auth/models/auth.method.ts b/src/app/core/auth/models/auth.method.ts
new file mode 100644
index 0000000000..617154080b
--- /dev/null
+++ b/src/app/core/auth/models/auth.method.ts
@@ -0,0 +1,38 @@
+import { AuthMethodType } from './auth.method-type';
+
+export class AuthMethod {
+ authMethodType: AuthMethodType;
+ location?: string;
+
+ // isStandalonePage? = true;
+
+ constructor(authMethodName: string, location?: string) {
+ switch (authMethodName) {
+ case 'ip': {
+ this.authMethodType = AuthMethodType.Ip;
+ break;
+ }
+ case 'ldap': {
+ this.authMethodType = AuthMethodType.Ldap;
+ break;
+ }
+ case 'shibboleth': {
+ this.authMethodType = AuthMethodType.Shibboleth;
+ this.location = location;
+ break;
+ }
+ case 'x509': {
+ this.authMethodType = AuthMethodType.X509;
+ break;
+ }
+ case 'password': {
+ this.authMethodType = AuthMethodType.Password;
+ break;
+ }
+
+ default: {
+ break;
+ }
+ }
+ }
+}
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..e69de29bb2
diff --git a/src/app/core/auth/selectors.ts b/src/app/core/auth/selectors.ts
index 8c88e0fce5..4e51bc1fc9 100644
--- a/src/app/core/auth/selectors.ts
+++ b/src/app/core/auth/selectors.ts
@@ -107,6 +107,17 @@ const _getRegistrationError = (state: AuthState) => state.error;
*/
const _getRedirectUrl = (state: AuthState) => state.redirectUrl;
+const _getAuthenticationMethods = (state: AuthState) => state.authMethods;
+
+/**
+ * Returns the authentication methods enabled at the backend
+ * @function getAuthenticationMethods
+ * @param {AuthState} state
+ * @param {any} props
+ * @return {any}
+ */
+export const getAuthenticationMethods = createSelector(getAuthState, _getAuthenticationMethods);
+
/**
* Returns the authenticated user
* @function getAuthenticatedUser
diff --git a/src/app/core/auth/server-auth.service.ts b/src/app/core/auth/server-auth.service.ts
index c8cba0206b..30767be85a 100644
--- a/src/app/core/auth/server-auth.service.ts
+++ b/src/app/core/auth/server-auth.service.ts
@@ -1,11 +1,11 @@
-import { HttpHeaders } from '@angular/common/http';
import { Injectable } from '@angular/core';
+import { HttpHeaders } from '@angular/common/http';
import { Observable } from 'rxjs';
import { filter, map, take } from 'rxjs/operators';
+
import { isNotEmpty } from '../../shared/empty.util';
import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service';
-import { CheckAuthenticationTokenAction } from './auth.actions';
import { AuthService } from './auth.service';
import { AuthStatus } from './models/auth-status.model';
import { AuthTokenInfo } from './models/auth-token-info.model';
@@ -43,10 +43,23 @@ export class ServerAuthService extends AuthService {
}
/**
- * Checks if token is present into browser storage and is valid. (NB Check is done only on SSR)
+ * Checks if token is present into the request cookie
*/
- public checkAuthenticationToken() {
- this.store.dispatch(new CheckAuthenticationTokenAction())
+ public checkAuthenticationCookie(): Observable {
+ // Determine if the user has an existing auth session on the server
+ const options: HttpOptions = Object.create({});
+ let headers = new HttpHeaders();
+ headers = headers.append('Accept', 'application/json');
+ if (isNotEmpty(this.req.protocol) && isNotEmpty(this.req.header('host'))) {
+ const referer = this.req.protocol + '://' + this.req.header('host') + this.req.path;
+ // use to allow the rest server to identify the real origin on SSR
+ headers = headers.append('X-Requested-With', referer);
+ }
+ options.headers = headers;
+ options.withCredentials = true;
+ return this.authRequestService.getRequest('status', options).pipe(
+ map((status: AuthStatus) => Object.assign(new AuthStatus(), status))
+ );
}
/**
diff --git a/src/app/core/core.module.ts b/src/app/core/core.module.ts
index 30a5f023ae..01a858113d 100644
--- a/src/app/core/core.module.ts
+++ b/src/app/core/core.module.ts
@@ -1,11 +1,8 @@
import { CommonModule } from '@angular/common';
import { HTTP_INTERCEPTORS, HttpClient } from '@angular/common/http';
import { ModuleWithProviders, NgModule, Optional, SkipSelf } from '@angular/core';
-import {
- DynamicFormLayoutService,
- DynamicFormService,
- DynamicFormValidationService
-} from '@ng-dynamic-forms/core';
+
+import { DynamicFormLayoutService, DynamicFormService, DynamicFormValidationService } from '@ng-dynamic-forms/core';
import { EffectsModule } from '@ngrx/effects';
import { Action, StoreConfig, StoreModule } from '@ngrx/store';
@@ -17,7 +14,6 @@ import { FormService } from '../shared/form/form.service';
import { HostWindowService } from '../shared/host-window.service';
import { MenuService } from '../shared/menu/menu.service';
import { EndpointMockingRestService } from '../shared/mocks/dspace-rest-v2/endpoint-mocking-rest.service';
-
import {
MOCK_RESPONSE_MAP,
ResponseMapMock,
@@ -47,7 +43,6 @@ import { SubmissionUploadsModel } from './config/models/config-submission-upload
import { SubmissionDefinitionsConfigService } from './config/submission-definitions-config.service';
import { SubmissionFormsConfigService } from './config/submission-forms-config.service';
import { SubmissionSectionsConfigService } from './config/submission-sections-config.service';
-
import { coreEffects } from './core.effects';
import { coreReducers, CoreState } from './core.reducers';
import { BitstreamFormatDataService } from './data/bitstream-format-data.service';
@@ -102,7 +97,6 @@ import { RegistryService } from './registry/registry.service';
import { RoleService } from './roles/role.service';
import { ApiService } from './services/api.service';
-import { RouteService } from './services/route.service';
import { ServerResponseService } from './services/server-response.service';
import { NativeWindowFactory, NativeWindowService } from './services/window.service';
import { BitstreamFormat } from './shared/bitstream-format.model';
@@ -143,6 +137,10 @@ import { PoolTaskDataService } from './tasks/pool-task-data.service';
import { TaskResponseParsingService } from './tasks/task-response-parsing.service';
import { environment } from '../../environments/environment';
import { storeModuleConfig } from '../app.reducer';
+import { VersionDataService } from './data/version-data.service';
+import { VersionHistoryDataService } from './data/version-history-data.service';
+import { Version } from './shared/version.model';
+import { VersionHistory } from './shared/version-history.model';
/**
* When not in production, endpoint responses can be mocked for testing purposes
@@ -212,7 +210,6 @@ const PROVIDERS = [
BrowseItemsResponseParsingService,
BrowseService,
ConfigResponseParsingService,
- RouteService,
SubmissionDefinitionsConfigService,
SubmissionFormsConfigService,
SubmissionRestService,
@@ -256,6 +253,8 @@ const PROVIDERS = [
RelationshipTypeService,
ExternalSourceService,
LookupRelationService,
+ VersionDataService,
+ VersionHistoryDataService,
LicenseDataService,
ItemTypeDataService,
// register AuthInterceptor as HttpInterceptor
@@ -306,6 +305,8 @@ export const models =
ItemType,
ExternalSource,
ExternalSourceEntry,
+ Version,
+ VersionHistory
];
@NgModule({
diff --git a/src/app/core/data/request.models.ts b/src/app/core/data/request.models.ts
index e17ffcac3f..0655333502 100644
--- a/src/app/core/data/request.models.ts
+++ b/src/app/core/data/request.models.ts
@@ -230,6 +230,8 @@ export class AuthPostRequest extends PostRequest {
}
export class AuthGetRequest extends GetRequest {
+ forceBypassCache = true;
+
constructor(uuid: string, href: string, public options?: HttpOptions) {
super(uuid, href, null, options);
}
diff --git a/src/app/core/data/version-data.service.ts b/src/app/core/data/version-data.service.ts
new file mode 100644
index 0000000000..80fb4f5b80
--- /dev/null
+++ b/src/app/core/data/version-data.service.ts
@@ -0,0 +1,44 @@
+import { Injectable } from '@angular/core';
+import { DataService } from './data.service';
+import { Version } from '../shared/version.model';
+import { RequestService } from './request.service';
+import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
+import { Store } from '@ngrx/store';
+import { CoreState } from '../core.reducers';
+import { ObjectCacheService } from '../cache/object-cache.service';
+import { HALEndpointService } from '../shared/hal-endpoint.service';
+import { NotificationsService } from '../../shared/notifications/notifications.service';
+import { HttpClient } from '@angular/common/http';
+import { DefaultChangeAnalyzer } from './default-change-analyzer.service';
+import { FindListOptions } from './request.models';
+import { Observable } from 'rxjs/internal/Observable';
+import { dataService } from '../cache/builders/build-decorators';
+import { VERSION } from '../shared/version.resource-type';
+
+/**
+ * Service responsible for handling requests related to the Version object
+ */
+@Injectable()
+@dataService(VERSION)
+export class VersionDataService extends DataService {
+ protected linkPath = 'versions';
+
+ constructor(
+ protected requestService: RequestService,
+ protected rdbService: RemoteDataBuildService,
+ protected store: Store,
+ protected objectCache: ObjectCacheService,
+ protected halService: HALEndpointService,
+ protected notificationsService: NotificationsService,
+ protected http: HttpClient,
+ protected comparator: DefaultChangeAnalyzer) {
+ super();
+ }
+
+ /**
+ * Get the endpoint for browsing versions
+ */
+ getBrowseEndpoint(options: FindListOptions = {}, linkPath?: string): Observable {
+ return this.halService.getEndpoint(this.linkPath);
+ }
+}
diff --git a/src/app/core/data/version-history-data.service.spec.ts b/src/app/core/data/version-history-data.service.spec.ts
new file mode 100644
index 0000000000..6728df71f1
--- /dev/null
+++ b/src/app/core/data/version-history-data.service.spec.ts
@@ -0,0 +1,54 @@
+import { RequestService } from './request.service';
+import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
+import { ObjectCacheService } from '../cache/object-cache.service';
+import { VersionHistoryDataService } from './version-history-data.service';
+import { NotificationsServiceStub } from '../../shared/testing/notifications-service-stub';
+import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service-stub';
+import { getMockRequestService } from '../../shared/mocks/mock-request.service';
+import { GetRequest } from './request.models';
+
+const url = 'fake-url';
+
+describe('VersionHistoryDataService', () => {
+ let service: VersionHistoryDataService;
+
+ let requestService: RequestService;
+ let notificationsService: any;
+ let rdbService: RemoteDataBuildService;
+ let objectCache: ObjectCacheService;
+ let halService: any;
+
+ beforeEach(() => {
+ createService();
+ });
+
+ describe('getVersions', () => {
+ let result;
+
+ beforeEach(() => {
+ result = service.getVersions('1');
+ });
+
+ it('should configure a GET request', () => {
+ expect(requestService.configure).toHaveBeenCalledWith(jasmine.any(GetRequest));
+ });
+ });
+
+ /**
+ * Create a VersionHistoryDataService used for testing
+ * @param requestEntry$ Supply a requestEntry to be returned by the REST API (optional)
+ */
+ function createService(requestEntry$?) {
+ requestService = getMockRequestService(requestEntry$);
+ rdbService = jasmine.createSpyObj('rdbService', {
+ buildList: jasmine.createSpy('buildList')
+ });
+ objectCache = jasmine.createSpyObj('objectCache', {
+ remove: jasmine.createSpy('remove')
+ });
+ halService = new HALEndpointServiceStub(url);
+ notificationsService = new NotificationsServiceStub();
+
+ service = new VersionHistoryDataService(requestService, rdbService, null, objectCache, halService, notificationsService, null, null);
+ }
+});
diff --git a/src/app/core/data/version-history-data.service.ts b/src/app/core/data/version-history-data.service.ts
new file mode 100644
index 0000000000..b4107d629d
--- /dev/null
+++ b/src/app/core/data/version-history-data.service.ts
@@ -0,0 +1,81 @@
+import { DataService } from './data.service';
+import { VersionHistory } from '../shared/version-history.model';
+import { Injectable } from '@angular/core';
+import { RequestService } from './request.service';
+import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
+import { Store } from '@ngrx/store';
+import { CoreState } from '../core.reducers';
+import { ObjectCacheService } from '../cache/object-cache.service';
+import { HALEndpointService } from '../shared/hal-endpoint.service';
+import { NotificationsService } from '../../shared/notifications/notifications.service';
+import { HttpClient } from '@angular/common/http';
+import { DefaultChangeAnalyzer } from './default-change-analyzer.service';
+import { FindListOptions, GetRequest } from './request.models';
+import { Observable } from 'rxjs/internal/Observable';
+import { PaginatedSearchOptions } from '../../shared/search/paginated-search-options.model';
+import { RemoteData } from './remote-data';
+import { PaginatedList } from './paginated-list';
+import { Version } from '../shared/version.model';
+import { map, switchMap, take } from 'rxjs/operators';
+import { dataService } from '../cache/builders/build-decorators';
+import { VERSION_HISTORY } from '../shared/version-history.resource-type';
+import { followLink, FollowLinkConfig } from '../../shared/utils/follow-link-config.model';
+
+/**
+ * Service responsible for handling requests related to the VersionHistory object
+ */
+@Injectable()
+@dataService(VERSION_HISTORY)
+export class VersionHistoryDataService extends DataService {
+ protected linkPath = 'versionhistories';
+ protected versionsEndpoint = 'versions';
+
+ constructor(
+ protected requestService: RequestService,
+ protected rdbService: RemoteDataBuildService,
+ protected store: Store,
+ protected objectCache: ObjectCacheService,
+ protected halService: HALEndpointService,
+ protected notificationsService: NotificationsService,
+ protected http: HttpClient,
+ protected comparator: DefaultChangeAnalyzer) {
+ super();
+ }
+
+ /**
+ * Get the endpoint for browsing versions
+ */
+ getBrowseEndpoint(options: FindListOptions = {}, linkPath?: string): Observable {
+ return this.halService.getEndpoint(this.linkPath);
+ }
+
+ /**
+ * Get the versions endpoint for a version history
+ * @param versionHistoryId
+ */
+ getVersionsEndpoint(versionHistoryId: string): Observable {
+ return this.getBrowseEndpoint().pipe(
+ switchMap((href: string) => this.halService.getEndpoint(this.versionsEndpoint, `${href}/${versionHistoryId}`))
+ );
+ }
+
+ /**
+ * Get a version history's versions using paginated search options
+ * @param versionHistoryId The version history's ID
+ * @param searchOptions The search options to use
+ * @param linksToFollow HAL Links to follow on the Versions
+ */
+ getVersions(versionHistoryId: string, searchOptions?: PaginatedSearchOptions, ...linksToFollow: Array>): Observable>> {
+ const hrefObs = this.getVersionsEndpoint(versionHistoryId).pipe(
+ map((href) => searchOptions ? searchOptions.toRestUrl(href) : href)
+ );
+ hrefObs.pipe(
+ take(1)
+ ).subscribe((href) => {
+ const request = new GetRequest(this.requestService.generateRequestId(), href);
+ this.requestService.configure(request);
+ });
+
+ return this.rdbService.buildList(hrefObs, ...linksToFollow);
+ }
+}
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 91756d412c..6eb144580c 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
@@ -90,6 +90,14 @@ export class DSpaceRESTv2Service {
requestOptions.headers = options.headers;
}
+ if (options && options.params) {
+ requestOptions.params = options.params;
+ }
+
+ if (options && options.withCredentials) {
+ requestOptions.withCredentials = options.withCredentials;
+ }
+
if (!requestOptions.headers.has('Content-Type')) {
// Because HttpHeaders is immutable, the set method returns a new object instead of updating the existing headers
requestOptions.headers = requestOptions.headers.set('Content-Type', DEFAULT_CONTENT_TYPE);
diff --git a/src/app/core/services/route.service.ts b/src/app/core/services/route.service.ts
index 441d058c4c..59ec899576 100644
--- a/src/app/core/services/route.service.ts
+++ b/src/app/core/services/route.service.ts
@@ -59,7 +59,9 @@ export function parameterSelector(key: string, paramsSelector: (state: CoreState
/**
* Service to keep track of the current query parameters
*/
-@Injectable()
+@Injectable({
+ providedIn: 'root'
+})
export class RouteService {
constructor(private route: ActivatedRoute, private router: Router, private store: Store) {
this.saveRouting();
diff --git a/src/app/core/shared/item.model.ts b/src/app/core/shared/item.model.ts
index e7f0ae9e10..7f6cf9fe13 100644
--- a/src/app/core/shared/item.model.ts
+++ b/src/app/core/shared/item.model.ts
@@ -18,6 +18,8 @@ import { Relationship } from './item-relationships/relationship.model';
import { RELATIONSHIP } from './item-relationships/relationship.resource-type';
import { ITEM } from './item.resource-type';
import { ChildHALResource } from './child-hal-resource.model';
+import { Version } from './version.model';
+import { VERSION } from './version.resource-type';
/**
* Class representing a DSpace Item
@@ -67,6 +69,7 @@ export class Item extends DSpaceObject implements ChildHALResource {
bundles: HALLink;
owningCollection: HALLink;
templateItemOf: HALLink;
+ version: HALLink;
self: HALLink;
};
@@ -77,6 +80,13 @@ export class Item extends DSpaceObject implements ChildHALResource {
@link(COLLECTION)
owningCollection?: Observable>;
+ /**
+ * The version this item represents in its history
+ * Will be undefined unless the version {@link HALLink} has been resolved.
+ */
+ @link(VERSION)
+ version?: Observable>;
+
/**
* The list of Bundles inside this Item
* Will be undefined unless the bundles {@link HALLink} has been resolved.
diff --git a/src/app/core/shared/version-history.model.ts b/src/app/core/shared/version-history.model.ts
new file mode 100644
index 0000000000..a8ce982fb2
--- /dev/null
+++ b/src/app/core/shared/version-history.model.ts
@@ -0,0 +1,39 @@
+import { deserialize, autoserialize, inheritSerialization } from 'cerialize';
+import { excludeFromEquals } from '../utilities/equals.decorators';
+import { Observable } from 'rxjs/internal/Observable';
+import { RemoteData } from '../data/remote-data';
+import { PaginatedList } from '../data/paginated-list';
+import { Version } from './version.model';
+import { VERSION_HISTORY } from './version-history.resource-type';
+import { link, typedObject } from '../cache/builders/build-decorators';
+import { DSpaceObject } from './dspace-object.model';
+import { HALLink } from './hal-link.model';
+import { VERSION } from './version.resource-type';
+
+/**
+ * Class representing a DSpace Version History
+ */
+@typedObject
+@inheritSerialization(DSpaceObject)
+export class VersionHistory extends DSpaceObject {
+ static type = VERSION_HISTORY;
+
+ @deserialize
+ _links: {
+ self: HALLink;
+ versions: HALLink;
+ };
+
+ /**
+ * The identifier of this Version History
+ */
+ @autoserialize
+ id: string;
+
+ /**
+ * The list of versions within this history
+ */
+ @excludeFromEquals
+ @link(VERSION, true)
+ versions: Observable>>;
+}
diff --git a/src/app/core/shared/version-history.resource-type.ts b/src/app/core/shared/version-history.resource-type.ts
new file mode 100644
index 0000000000..c6d92ce138
--- /dev/null
+++ b/src/app/core/shared/version-history.resource-type.ts
@@ -0,0 +1,9 @@
+import { ResourceType } from './resource-type';
+
+/**
+ * The resource type for VersionHistory
+ *
+ * Needs to be in a separate file to prevent circular
+ * dependencies in webpack.
+ */
+export const VERSION_HISTORY = new ResourceType('versionhistory');
diff --git a/src/app/core/shared/version.model.ts b/src/app/core/shared/version.model.ts
new file mode 100644
index 0000000000..6e109ba9c2
--- /dev/null
+++ b/src/app/core/shared/version.model.ts
@@ -0,0 +1,76 @@
+import { deserialize, autoserialize, inheritSerialization } from 'cerialize';
+import { excludeFromEquals } from '../utilities/equals.decorators';
+import { Item } from './item.model';
+import { RemoteData } from '../data/remote-data';
+import { Observable } from 'rxjs/internal/Observable';
+import { VersionHistory } from './version-history.model';
+import { EPerson } from '../eperson/models/eperson.model';
+import { VERSION } from './version.resource-type';
+import { HALLink } from './hal-link.model';
+import { link, typedObject } from '../cache/builders/build-decorators';
+import { VERSION_HISTORY } from './version-history.resource-type';
+import { ITEM } from './item.resource-type';
+import { EPERSON } from '../eperson/models/eperson.resource-type';
+import { DSpaceObject } from './dspace-object.model';
+
+/**
+ * Class representing a DSpace Version
+ */
+@typedObject
+@inheritSerialization(DSpaceObject)
+export class Version extends DSpaceObject {
+ static type = VERSION;
+
+ @deserialize
+ _links: {
+ self: HALLink;
+ item: HALLink;
+ versionhistory: HALLink;
+ eperson: HALLink;
+ };
+
+ /**
+ * The identifier of this Version
+ */
+ @autoserialize
+ id: string;
+
+ /**
+ * The version number of the version's history this version represents
+ */
+ @autoserialize
+ version: number;
+
+ /**
+ * The summary for the changes made in this version
+ */
+ @autoserialize
+ summary: string;
+
+ /**
+ * The Date this version was created
+ */
+ @deserialize
+ created: Date;
+
+ /**
+ * The full version history this version is apart of
+ */
+ @excludeFromEquals
+ @link(VERSION_HISTORY)
+ versionhistory: Observable>;
+
+ /**
+ * The item this version represents
+ */
+ @excludeFromEquals
+ @link(ITEM)
+ item: Observable>;
+
+ /**
+ * The e-person who created this version
+ */
+ @excludeFromEquals
+ @link(EPERSON)
+ eperson: Observable>;
+}
diff --git a/src/app/core/shared/version.resource-type.ts b/src/app/core/shared/version.resource-type.ts
new file mode 100644
index 0000000000..ac0f56239e
--- /dev/null
+++ b/src/app/core/shared/version.resource-type.ts
@@ -0,0 +1,9 @@
+import { ResourceType } from './resource-type';
+
+/**
+ * The resource type for Version
+ *
+ * Needs to be in a separate file to prevent circular
+ * dependencies in webpack.
+ */
+export const VERSION = new ResourceType('version');
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 86de30c23e..a05381fee8 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
@@ -1,26 +1,33 @@
- -
+
-
-
{{ 'nav.login' | translate }}
-
-
- {{ 'nav.login' | translate }}(current)
+ {{ 'nav.login' | translate }}(current)
-
-
- (current)
+ (current)
diff --git a/src/app/shared/item/item-versions/item-versions.component.html b/src/app/shared/item/item-versions/item-versions.component.html
new file mode 100644
index 0000000000..6e93f4c7ca
--- /dev/null
+++ b/src/app/shared/item/item-versions/item-versions.component.html
@@ -0,0 +1,47 @@
+
+
+
0 || displayWhenEmpty">
+
{{"item.version.history.head" | translate}}
+
0"
+ [hideGear]="true"
+ [hidePagerWhenSinglePage]="true"
+ [paginationOptions]="options"
+ [pageInfoState]="versions"
+ [collectionSize]="versions?.totalElements"
+ [disableRouteParameterUpdate]="true"
+ (pageChange)="switchPage($event)">
+
+
+
+ {{"item.version.history.table.version" | translate}} |
+ {{"item.version.history.table.item" | translate}} |
+ {{"item.version.history.table.editor" | translate}} |
+ {{"item.version.history.table.date" | translate}} |
+ {{"item.version.history.table.summary" | translate}} |
+
+
+
+
+ {{version?.version}} |
+
+
+ {{item?.handle}}
+ *
+
+ |
+
+
+ {{eperson?.name}}
+
+ |
+ {{version?.created}} |
+ {{version?.summary}} |
+
+
+
+ * {{"item.version.history.selected" | translate}}
+
+
+
+
+
diff --git a/src/app/shared/item/item-versions/item-versions.component.spec.ts b/src/app/shared/item/item-versions/item-versions.component.spec.ts
new file mode 100644
index 0000000000..18fa4cf983
--- /dev/null
+++ b/src/app/shared/item/item-versions/item-versions.component.spec.ts
@@ -0,0 +1,121 @@
+import { ItemVersionsComponent } from './item-versions.component';
+import { async, ComponentFixture, TestBed } from '@angular/core/testing';
+import { VarDirective } from '../../utils/var.directive';
+import { TranslateModule } from '@ngx-translate/core';
+import { RouterTestingModule } from '@angular/router/testing';
+import { NO_ERRORS_SCHEMA } from '@angular/core';
+import { Item } from '../../../core/shared/item.model';
+import { Version } from '../../../core/shared/version.model';
+import { VersionHistory } from '../../../core/shared/version-history.model';
+import { createPaginatedList, createSuccessfulRemoteDataObject$ } from '../../testing/utils';
+import { VersionHistoryDataService } from '../../../core/data/version-history-data.service';
+import { By } from '@angular/platform-browser';
+
+describe('ItemVersionsComponent', () => {
+ let component: ItemVersionsComponent;
+ let fixture: ComponentFixture
;
+
+ const versionHistory = Object.assign(new VersionHistory(), {
+ id: '1'
+ });
+ const version1 = Object.assign(new Version(), {
+ id: '1',
+ version: 1,
+ created: new Date(2020, 1, 1),
+ summary: 'first version',
+ versionhistory: createSuccessfulRemoteDataObject$(versionHistory)
+ });
+ const version2 = Object.assign(new Version(), {
+ id: '2',
+ version: 2,
+ summary: 'second version',
+ created: new Date(2020, 1, 2),
+ versionhistory: createSuccessfulRemoteDataObject$(versionHistory)
+ });
+ const versions = [version1, version2];
+ versionHistory.versions = createSuccessfulRemoteDataObject$(createPaginatedList(versions));
+ const item1 = Object.assign(new Item(), {
+ uuid: 'item-identifier-1',
+ handle: '123456789/1',
+ version: createSuccessfulRemoteDataObject$(version1)
+ });
+ const item2 = Object.assign(new Item(), {
+ uuid: 'item-identifier-2',
+ handle: '123456789/2',
+ version: createSuccessfulRemoteDataObject$(version2)
+ });
+ const items = [item1, item2];
+ version1.item = createSuccessfulRemoteDataObject$(item1);
+ version2.item = createSuccessfulRemoteDataObject$(item2);
+ const versionHistoryService = jasmine.createSpyObj('versionHistoryService', {
+ getVersions: createSuccessfulRemoteDataObject$(createPaginatedList(versions))
+ });
+
+ beforeEach(async(() => {
+ TestBed.configureTestingModule({
+ declarations: [ItemVersionsComponent, VarDirective],
+ imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([])],
+ providers: [
+ { provide: VersionHistoryDataService, useValue: versionHistoryService }
+ ],
+ schemas: [NO_ERRORS_SCHEMA]
+ }).compileComponents();
+ }));
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(ItemVersionsComponent);
+ component = fixture.componentInstance;
+ component.item = item1;
+ fixture.detectChanges();
+ });
+
+ it(`should display ${versions.length} rows`, () => {
+ const rows = fixture.debugElement.queryAll(By.css('tbody tr'));
+ expect(rows.length).toBe(versions.length);
+ });
+
+ versions.forEach((version: Version, index: number) => {
+ const versionItem = items[index];
+
+ it(`should display version ${version.version} in the correct column for version ${version.id}`, () => {
+ const id = fixture.debugElement.query(By.css(`#version-row-${version.id} .version-row-element-version`));
+ expect(id.nativeElement.textContent).toEqual('' + version.version);
+ });
+
+ it(`should display item handle ${versionItem.handle} in the correct column for version ${version.id}`, () => {
+ const item = fixture.debugElement.query(By.css(`#version-row-${version.id} .version-row-element-item`));
+ expect(item.nativeElement.textContent).toContain(versionItem.handle);
+ });
+
+ // This version's item is equal to the component's item (the selected item)
+ // Check if the handle contains an asterisk
+ if (item1.uuid === versionItem.uuid) {
+ it('should add an asterisk to the handle of the selected item', () => {
+ const item = fixture.debugElement.query(By.css(`#version-row-${version.id} .version-row-element-item`));
+ expect(item.nativeElement.textContent).toContain('*');
+ });
+ }
+
+ it(`should display date ${version.created} in the correct column for version ${version.id}`, () => {
+ const date = fixture.debugElement.query(By.css(`#version-row-${version.id} .version-row-element-date`));
+ expect(date.nativeElement.textContent).toEqual('' + version.created);
+ });
+
+ it(`should display summary ${version.summary} in the correct column for version ${version.id}`, () => {
+ const summary = fixture.debugElement.query(By.css(`#version-row-${version.id} .version-row-element-summary`));
+ expect(summary.nativeElement.textContent).toEqual(version.summary);
+ });
+ });
+
+ describe('switchPage', () => {
+ const page = 5;
+
+ beforeEach(() => {
+ component.switchPage(page);
+ });
+
+ it('should set the option\'s currentPage to the new page', () => {
+ expect(component.options.currentPage).toEqual(page);
+ });
+ });
+});
diff --git a/src/app/shared/item/item-versions/item-versions.component.ts b/src/app/shared/item/item-versions/item-versions.component.ts
new file mode 100644
index 0000000000..684599d3b5
--- /dev/null
+++ b/src/app/shared/item/item-versions/item-versions.component.ts
@@ -0,0 +1,130 @@
+import { Component, Input, OnInit } from '@angular/core';
+import { Item } from '../../../core/shared/item.model';
+import { Version } from '../../../core/shared/version.model';
+import { RemoteData } from '../../../core/data/remote-data';
+import { Observable } from 'rxjs/internal/Observable';
+import { VersionHistory } from '../../../core/shared/version-history.model';
+import { getAllSucceededRemoteData, getRemoteDataPayload } from '../../../core/shared/operators';
+import { map, startWith, switchMap } from 'rxjs/operators';
+import { combineLatest as observableCombineLatest } from 'rxjs';
+import { PaginatedList } from '../../../core/data/paginated-list';
+import { PaginationComponentOptions } from '../../pagination/pagination-component-options.model';
+import { BehaviorSubject } from 'rxjs/internal/BehaviorSubject';
+import { VersionHistoryDataService } from '../../../core/data/version-history-data.service';
+import { PaginatedSearchOptions } from '../../search/paginated-search-options.model';
+import { AlertType } from '../../alert/aletr-type';
+import { followLink } from '../../utils/follow-link-config.model';
+
+@Component({
+ selector: 'ds-item-versions',
+ templateUrl: './item-versions.component.html'
+})
+/**
+ * Component listing all available versions of the history the provided item is a part of
+ */
+export class ItemVersionsComponent implements OnInit {
+ /**
+ * The item to display a version history for
+ */
+ @Input() item: Item;
+
+ /**
+ * An option to display the list of versions, even when there aren't any.
+ * Instead of the table, an alert will be displayed, notifying the user there are no other versions present
+ * for the current item.
+ */
+ @Input() displayWhenEmpty = false;
+
+ /**
+ * Whether or not to display the title
+ */
+ @Input() displayTitle = true;
+
+ /**
+ * The AlertType enumeration
+ * @type {AlertType}
+ */
+ AlertTypeEnum = AlertType;
+
+ /**
+ * The item's version
+ */
+ versionRD$: Observable>;
+
+ /**
+ * The item's full version history
+ */
+ versionHistoryRD$: Observable>;
+
+ /**
+ * The version history's list of versions
+ */
+ versionsRD$: Observable>>;
+
+ /**
+ * Verify if the list of versions has at least one e-person to display
+ * Used to hide the "Editor" column when no e-persons are present to display
+ */
+ hasEpersons$: Observable;
+
+ /**
+ * The amount of versions to display per page
+ */
+ pageSize = 10;
+
+ /**
+ * The page options to use for fetching the versions
+ * Start at page 1 and always use the set page size
+ */
+ options = Object.assign(new PaginationComponentOptions(),{
+ id: 'item-versions-options',
+ currentPage: 1,
+ pageSize: this.pageSize
+ });
+
+ /**
+ * The current page being displayed
+ */
+ currentPage$ = new BehaviorSubject(1);
+
+ constructor(private versionHistoryService: VersionHistoryDataService) {
+ }
+
+ /**
+ * Initialize all observables
+ */
+ ngOnInit(): void {
+ this.versionRD$ = this.item.version;
+ this.versionHistoryRD$ = this.versionRD$.pipe(
+ getAllSucceededRemoteData(),
+ getRemoteDataPayload(),
+ switchMap((version: Version) => version.versionhistory)
+ );
+ const versionHistory$ = this.versionHistoryRD$.pipe(
+ getAllSucceededRemoteData(),
+ getRemoteDataPayload(),
+ );
+ this.versionsRD$ = observableCombineLatest(versionHistory$, this.currentPage$).pipe(
+ switchMap(([versionHistory, page]: [VersionHistory, number]) =>
+ this.versionHistoryService.getVersions(versionHistory.id,
+ new PaginatedSearchOptions({pagination: Object.assign({}, this.options, { currentPage: page })}),
+ followLink('item'), followLink('eperson')))
+ );
+ this.hasEpersons$ = this.versionsRD$.pipe(
+ getAllSucceededRemoteData(),
+ getRemoteDataPayload(),
+ map((versions: PaginatedList) => versions.page.filter((version: Version) => version.eperson !== undefined).length > 0),
+ startWith(false)
+ );
+ }
+
+ /**
+ * Update the current page
+ * @param page
+ */
+ switchPage(page: number) {
+ this.options.currentPage = page;
+ this.currentPage$.next(page);
+ }
+
+}
diff --git a/src/app/shared/item/item-versions/notice/item-versions-notice.component.html b/src/app/shared/item/item-versions/notice/item-versions-notice.component.html
new file mode 100644
index 0000000000..cec0bdcb04
--- /dev/null
+++ b/src/app/shared/item/item-versions/notice/item-versions-notice.component.html
@@ -0,0 +1,5 @@
+
+
diff --git a/src/app/shared/item/item-versions/notice/item-versions-notice.component.spec.ts b/src/app/shared/item/item-versions/notice/item-versions-notice.component.spec.ts
new file mode 100644
index 0000000000..ffcd1d897e
--- /dev/null
+++ b/src/app/shared/item/item-versions/notice/item-versions-notice.component.spec.ts
@@ -0,0 +1,93 @@
+import { ItemVersionsNoticeComponent } from './item-versions-notice.component';
+import { async, ComponentFixture, TestBed } from '@angular/core/testing';
+import { TranslateModule } from '@ngx-translate/core';
+import { RouterTestingModule } from '@angular/router/testing';
+import { NO_ERRORS_SCHEMA } from '@angular/core';
+import { VersionHistory } from '../../../../core/shared/version-history.model';
+import { Version } from '../../../../core/shared/version.model';
+import { createPaginatedList, createSuccessfulRemoteDataObject$ } from '../../../testing/utils';
+import { Item } from '../../../../core/shared/item.model';
+import { VersionHistoryDataService } from '../../../../core/data/version-history-data.service';
+import { By } from '@angular/platform-browser';
+
+describe('ItemVersionsNoticeComponent', () => {
+ let component: ItemVersionsNoticeComponent;
+ let fixture: ComponentFixture;
+
+ const versionHistory = Object.assign(new VersionHistory(), {
+ id: '1'
+ });
+ const firstVersion = Object.assign(new Version(), {
+ id: '1',
+ version: 1,
+ created: new Date(2020, 1, 1),
+ summary: 'first version',
+ versionhistory: createSuccessfulRemoteDataObject$(versionHistory)
+ });
+ const latestVersion = Object.assign(new Version(), {
+ id: '2',
+ version: 2,
+ summary: 'latest version',
+ created: new Date(2020, 1, 2),
+ versionhistory: createSuccessfulRemoteDataObject$(versionHistory)
+ });
+ const versions = [latestVersion, firstVersion];
+ versionHistory.versions = createSuccessfulRemoteDataObject$(createPaginatedList(versions));
+ const firstItem = Object.assign(new Item(), {
+ id: 'first_item_id',
+ uuid: 'first_item_id',
+ handle: '123456789/1',
+ version: createSuccessfulRemoteDataObject$(firstVersion)
+ });
+ const latestItem = Object.assign(new Item(), {
+ id: 'latest_item_id',
+ uuid: 'latest_item_id',
+ handle: '123456789/2',
+ version: createSuccessfulRemoteDataObject$(latestVersion)
+ });
+ firstVersion.item = createSuccessfulRemoteDataObject$(firstItem);
+ latestVersion.item = createSuccessfulRemoteDataObject$(latestItem);
+ const versionHistoryService = jasmine.createSpyObj('versionHistoryService', {
+ getVersions: createSuccessfulRemoteDataObject$(createPaginatedList(versions))
+ });
+
+ beforeEach(async(() => {
+ TestBed.configureTestingModule({
+ declarations: [ItemVersionsNoticeComponent],
+ imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([])],
+ providers: [
+ { provide: VersionHistoryDataService, useValue: versionHistoryService }
+ ],
+ schemas: [NO_ERRORS_SCHEMA]
+ }).compileComponents();
+ }));
+
+ describe('when the item is the latest version', () => {
+ beforeEach(() => {
+ initComponentWithItem(latestItem);
+ });
+
+ it('should not display a notice', () => {
+ const alert = fixture.debugElement.query(By.css('ds-alert'));
+ expect(alert).toBeNull();
+ });
+ });
+
+ describe('when the item is not the latest version', () => {
+ beforeEach(() => {
+ initComponentWithItem(firstItem);
+ });
+
+ it('should display a notice', () => {
+ const alert = fixture.debugElement.query(By.css('ds-alert'));
+ expect(alert).not.toBeNull();
+ });
+ });
+
+ function initComponentWithItem(item: Item) {
+ fixture = TestBed.createComponent(ItemVersionsNoticeComponent);
+ component = fixture.componentInstance;
+ component.item = item;
+ fixture.detectChanges();
+ }
+});
diff --git a/src/app/shared/item/item-versions/notice/item-versions-notice.component.ts b/src/app/shared/item/item-versions/notice/item-versions-notice.component.ts
new file mode 100644
index 0000000000..c2bd316137
--- /dev/null
+++ b/src/app/shared/item/item-versions/notice/item-versions-notice.component.ts
@@ -0,0 +1,114 @@
+import { Component, Input, OnInit } from '@angular/core';
+import { Item } from '../../../../core/shared/item.model';
+import { PaginationComponentOptions } from '../../../pagination/pagination-component-options.model';
+import { PaginatedSearchOptions } from '../../../search/paginated-search-options.model';
+import { Observable } from 'rxjs/internal/Observable';
+import { RemoteData } from '../../../../core/data/remote-data';
+import { VersionHistory } from '../../../../core/shared/version-history.model';
+import { Version } from '../../../../core/shared/version.model';
+import { hasValue } from '../../../empty.util';
+import { getAllSucceededRemoteData, getRemoteDataPayload } from '../../../../core/shared/operators';
+import { filter, map, startWith, switchMap } from 'rxjs/operators';
+import { followLink } from '../../../utils/follow-link-config.model';
+import { VersionHistoryDataService } from '../../../../core/data/version-history-data.service';
+import { AlertType } from '../../../alert/aletr-type';
+import { combineLatest as observableCombineLatest } from 'rxjs';
+import { getItemPageRoute } from '../../../../+item-page/item-page-routing.module';
+
+@Component({
+ selector: 'ds-item-versions-notice',
+ templateUrl: './item-versions-notice.component.html'
+})
+/**
+ * Component for displaying a warning notice when the item is not the latest version within its version history
+ * The notice contains a link to the latest version's item page
+ */
+export class ItemVersionsNoticeComponent implements OnInit {
+ /**
+ * The item to display a version notice for
+ */
+ @Input() item: Item;
+
+ /**
+ * The item's version
+ */
+ versionRD$: Observable>;
+
+ /**
+ * The item's full version history
+ */
+ versionHistoryRD$: Observable>;
+
+ /**
+ * The latest version of the item's version history
+ */
+ latestVersion$: Observable;
+
+ /**
+ * Is the item's version equal to the latest version from the version history?
+ * This will determine whether or not to display a notice linking to the latest version
+ */
+ isLatestVersion$: Observable;
+
+ /**
+ * Pagination options to fetch a single version on the first page (this is the latest version in the history)
+ */
+ latestVersionOptions = Object.assign(new PaginationComponentOptions(),{
+ id: 'item-newest-version-options',
+ currentPage: 1,
+ pageSize: 1
+ });
+
+ /**
+ * The AlertType enumeration
+ * @type {AlertType}
+ */
+ public AlertTypeEnum = AlertType;
+
+ constructor(private versionHistoryService: VersionHistoryDataService) {
+ }
+
+ /**
+ * Initialize the component's observables
+ */
+ ngOnInit(): void {
+ const latestVersionSearch = new PaginatedSearchOptions({pagination: this.latestVersionOptions});
+ if (hasValue(this.item.version)) {
+ this.versionRD$ = this.item.version;
+ this.versionHistoryRD$ = this.versionRD$.pipe(
+ getAllSucceededRemoteData(),
+ getRemoteDataPayload(),
+ switchMap((version: Version) => version.versionhistory)
+ );
+ const versionHistory$ = this.versionHistoryRD$.pipe(
+ getAllSucceededRemoteData(),
+ getRemoteDataPayload(),
+ );
+ this.latestVersion$ = versionHistory$.pipe(
+ switchMap((versionHistory: VersionHistory) =>
+ this.versionHistoryService.getVersions(versionHistory.id, latestVersionSearch, followLink('item'))),
+ getAllSucceededRemoteData(),
+ getRemoteDataPayload(),
+ filter((versions) => versions.page.length > 0),
+ map((versions) => versions.page[0])
+ );
+
+ this.isLatestVersion$ = observableCombineLatest(
+ this.versionRD$.pipe(getAllSucceededRemoteData(), getRemoteDataPayload()), this.latestVersion$
+ ).pipe(
+ map(([itemVersion, latestVersion]: [Version, Version]) => itemVersion.id === latestVersion.id),
+ startWith(true)
+ )
+ }
+ }
+
+ /**
+ * Get the item page url
+ * @param item The item for which the url is requested
+ */
+ getItemPage(item: Item): string {
+ if (hasValue(item)) {
+ return getItemPageRoute(item.id);
+ }
+ }
+}
diff --git a/src/app/shared/log-in/container/log-in-container.component.html b/src/app/shared/log-in/container/log-in-container.component.html
new file mode 100644
index 0000000000..bef6f43b66
--- /dev/null
+++ b/src/app/shared/log-in/container/log-in-container.component.html
@@ -0,0 +1,5 @@
+
+
+
diff --git a/src/app/shared/log-in/container/log-in-container.component.scss b/src/app/shared/log-in/container/log-in-container.component.scss
new file mode 100644
index 0000000000..0255b71dac
--- /dev/null
+++ b/src/app/shared/log-in/container/log-in-container.component.scss
@@ -0,0 +1,21 @@
+:host ::ng-deep .card {
+ margin-bottom: $submission-sections-margin-bottom;
+ overflow: unset;
+}
+
+.section-focus {
+ border-radius: $border-radius;
+ box-shadow: $btn-focus-box-shadow;
+}
+
+// TODO to remove the following when upgrading @ng-bootstrap
+:host ::ng-deep .card:first-of-type {
+ border-bottom: $card-border-width solid $card-border-color !important;
+ border-bottom-left-radius: $card-border-radius !important;
+ border-bottom-right-radius: $card-border-radius !important;
+}
+
+:host ::ng-deep .card-header button {
+ box-shadow: none !important;
+ width: 100%;
+}
diff --git a/src/app/shared/log-in/container/log-in-container.component.spec.ts b/src/app/shared/log-in/container/log-in-container.component.spec.ts
new file mode 100644
index 0000000000..c819b0cc8d
--- /dev/null
+++ b/src/app/shared/log-in/container/log-in-container.component.spec.ts
@@ -0,0 +1,108 @@
+import { Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
+import { async, ComponentFixture, inject, TestBed } from '@angular/core/testing';
+import { FormsModule, ReactiveFormsModule } from '@angular/forms';
+
+import { StoreModule } from '@ngrx/store';
+import { TranslateModule } from '@ngx-translate/core';
+
+import { LogInContainerComponent } from './log-in-container.component';
+import { authReducer } from '../../../core/auth/auth.reducer';
+import { SharedModule } from '../../shared.module';
+import { createTestComponent } from '../../testing/utils';
+import { AuthService } from '../../../core/auth/auth.service';
+import { AuthMethod } from '../../../core/auth/models/auth.method';
+import { AuthServiceStub } from '../../testing/auth-service-stub';
+
+describe('LogInContainerComponent', () => {
+
+ let component: LogInContainerComponent;
+ let fixture: ComponentFixture;
+
+ const authMethod = new AuthMethod('password');
+
+ beforeEach(async(() => {
+ // refine the test module by declaring the test component
+ TestBed.configureTestingModule({
+ imports: [
+ FormsModule,
+ ReactiveFormsModule,
+ StoreModule.forRoot(authReducer),
+ SharedModule,
+ TranslateModule.forRoot()
+ ],
+ declarations: [
+ TestComponent
+ ],
+ providers: [
+ {provide: AuthService, useClass: AuthServiceStub},
+ LogInContainerComponent
+ ],
+ schemas: [
+ CUSTOM_ELEMENTS_SCHEMA
+ ]
+ })
+ .compileComponents();
+
+ }));
+
+ describe('', () => {
+ let testComp: TestComponent;
+ let testFixture: ComponentFixture;
+
+ // synchronous beforeEach
+ beforeEach(() => {
+ const html = ` `;
+
+ testFixture = createTestComponent(html, TestComponent) as ComponentFixture;
+ testComp = testFixture.componentInstance;
+ });
+
+ afterEach(() => {
+ testFixture.destroy();
+ });
+
+ it('should create LogInContainerComponent', inject([LogInContainerComponent], (app: LogInContainerComponent) => {
+
+ expect(app).toBeDefined();
+
+ }));
+ });
+
+ describe('', () => {
+ beforeEach(() => {
+ fixture = TestBed.createComponent(LogInContainerComponent);
+ component = fixture.componentInstance;
+
+ spyOn(component, 'getAuthMethodContent').and.callThrough();
+ component.authMethod = authMethod;
+ fixture.detectChanges();
+ });
+
+ afterEach(() => {
+ fixture.destroy();
+ component = null;
+ });
+
+ it('should inject component properly', () => {
+
+ component.ngOnInit();
+ fixture.detectChanges();
+
+ expect(component.getAuthMethodContent).toHaveBeenCalled();
+
+ });
+
+ });
+
+});
+
+// declare a test component
+@Component({
+ selector: 'ds-test-cmp',
+ template: ``
+})
+class TestComponent {
+
+ isStandalonePage = true;
+
+}
diff --git a/src/app/shared/log-in/container/log-in-container.component.ts b/src/app/shared/log-in/container/log-in-container.component.ts
new file mode 100644
index 0000000000..660e616b9d
--- /dev/null
+++ b/src/app/shared/log-in/container/log-in-container.component.ts
@@ -0,0 +1,51 @@
+import { Component, Injector, Input, OnInit } from '@angular/core';
+
+import { rendersAuthMethodType } from '../methods/log-in.methods-decorator';
+import { AuthMethod } from '../../../core/auth/models/auth.method';
+
+/**
+ * This component represents a component container for log-in methods available.
+ */
+@Component({
+ selector: 'ds-log-in-container',
+ templateUrl: './log-in-container.component.html',
+ styleUrls: ['./log-in-container.component.scss']
+})
+export class LogInContainerComponent implements OnInit {
+
+ @Input() authMethod: AuthMethod;
+
+ /**
+ * Injector to inject a section component with the @Input parameters
+ * @type {Injector}
+ */
+ public objectInjector: Injector;
+
+ /**
+ * Initialize instance variables
+ *
+ * @param {Injector} injector
+ */
+ constructor(private injector: Injector) {
+ }
+
+ /**
+ * Initialize all instance variables
+ */
+ ngOnInit() {
+ this.objectInjector = Injector.create({
+ providers: [
+ { provide: 'authMethodProvider', useFactory: () => (this.authMethod), deps: [] },
+ ],
+ parent: this.injector
+ });
+ }
+
+ /**
+ * Find the correct component based on the AuthMethod's type
+ */
+ getAuthMethodContent(): string {
+ return rendersAuthMethodType(this.authMethod.authMethodType)
+ }
+
+}
diff --git a/src/app/shared/log-in/log-in.component.html b/src/app/shared/log-in/log-in.component.html
index fe9a506e71..8e23f00d9b 100644
--- a/src/app/shared/log-in/log-in.component.html
+++ b/src/app/shared/log-in/log-in.component.html
@@ -1,28 +1,13 @@
-