diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
new file mode 100644
index 0000000000..682f67294b
--- /dev/null
+++ b/.github/pull_request_template.md
@@ -0,0 +1,28 @@
+## References
+_Add references/links to any related tickets or PRs. These may include:_
+* Link to [Angular issue or PR](https://github.com/DSpace/dspace-angular/issues) related to this PR, if any
+* Link to [JIRA](https://jira.lyrasis.org/projects/DS/summary) ticket(s), if any
+
+## Description
+Short summary of changes (1-2 sentences).
+
+## Instructions for Reviewers
+Please add a more detailed description of the changes made by your PR. At a minimum, providing a bulleted list of changes in your PR is helpful to reviewers.
+
+List of changes in this PR:
+* First, ...
+* Second, ...
+
+**Include guidance for how to test or review your PR.** This may include: steps to reproduce a bug, screenshots or description of a new feature, or reasons behind specific changes.
+
+## Checklist
+_This checklist provides a reminder of what we are going to look for when reviewing your PR. You need not complete this checklist prior to creating your PR (draft PRs are always welcome). If you are unsure about an item in the checklist, don't hesitate to ask. We're here to help!_
+
+- [ ] My PR is small in size (e.g. less than 1,000 lines of code, not including comments & specs/tests), or I have provided reasons as to why that's not possible.
+- [ ] My PR passes [TSLint](https://palantir.github.io/tslint/) validation using `yarn run lint`
+- [ ] My PR includes [TypeDoc](https://typedoc.org/) comments for _all new (or modified) public methods and classes_. It also includes TypeDoc for large or complex private methods.
+- [ ] My PR passes all specs/tests and includes new/updated specs for any bug fixes, improvements or new features. A few reminders about what constitutes good tests:
+ * Include tests for different user types (if behavior differs), including: (1) Anonymous user, (2) Logged in user (non-admin), and (3) Administrator.
+ * Include tests for error scenarios, e.g. when errors/warnings should appear (or buttons should be disabled).
+ * For bug fixes, include a test that reproduces the bug and proves it is fixed. For clarity, it may be useful to provide the test in a separate commit from the bug fix.
+- [ ] If my PR includes new, third-party dependencies (in `package.json`), I've made sure their licenses align with the [DSpace BSD License](https://github.com/DSpace/DSpace/blob/master/LICENSE) based on the [Licensing of Contributions](https://wiki.lyrasis.org/display/DSPACE/Code+Contribution+Guidelines#CodeContributionGuidelines-LicensingofContributions) documentation.
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000000..b7cb98fe83
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,39 @@
+DSpace source code BSD License:
+
+Copyright (c) 2002-2020, LYRASIS. All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are
+met:
+
+- Redistributions of source code must retain the above copyright
+notice, this list of conditions and the following disclaimer.
+
+- Redistributions in binary form must reproduce the above copyright
+notice, this list of conditions and the following disclaimer in the
+documentation and/or other materials provided with the distribution.
+
+- Neither the name DuraSpace nor the name of the DSpace Foundation
+nor the names of its contributors may be used to endorse or promote
+products derived from this software without specific prior written
+permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+HOLDERS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
+INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
+BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS
+OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
+ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR
+TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE
+USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH
+DAMAGE.
+
+
+DSpace uses third-party libraries which may be distributed under
+different licenses to the above. Information about these licenses
+is detailed in the LICENSES_THIRD_PARTY file at the root of the source
+tree. You must agree to the terms of these licenses, in addition to
+the above DSpace source code license, in order to use this software.
diff --git a/LICENSES_THIRD_PARTY b/LICENSES_THIRD_PARTY
new file mode 100644
index 0000000000..c42cc0b255
--- /dev/null
+++ b/LICENSES_THIRD_PARTY
@@ -0,0 +1,15 @@
+
+DSpace uses third-party libraries which may be distributed under different licenses.
+A summary of all third-party, production dependencies used by this user interface may be found by running:
+
+ npx license-checker --production --summary
+
+(Additional license-checker options may be found in its documentation: https://github.com/davglass/license-checker)
+
+You must agree to the terms of these licenses, in addition to the DSpace source code license, in order to use this
+software.
+
+PLEASE NOTE: Some third-party dependencies may be listed under multiple licenses if they are dual-licensed.
+This is especially true of anything listed as GPL (or similar), as DSpace does NOT allow for the inclusion of
+any dependencies that are solely released under GPL (or similar) terms. For more info see:
+https://wiki.lyrasis.org/display/DSPACE/Code+Contribution+Guidelines#CodeContributionGuidelines-LicensingofContributions
diff --git a/resources/i18n/en.json5 b/resources/i18n/en.json5
index c869f51e5f..7664b8967a 100644
--- a/resources/i18n/en.json5
+++ b/resources/i18n/en.json5
@@ -38,7 +38,7 @@
"admin.registries.bitstream-formats.edit.extensions.label": "File extensions",
- "admin.registries.bitstream-formats.edit.extensions.placeholder": "Enter a file extenstion without the dot",
+ "admin.registries.bitstream-formats.edit.extensions.placeholder": "Enter a file extension without the dot",
"admin.registries.bitstream-formats.edit.failure.content": "An error occurred while editing the bitstream format.",
@@ -1115,6 +1115,10 @@
+ "item.version.notice": "This is not the latest version of this item. The latest version can be found here.",
+
+
+
"journal.listelement.badge": "Journal",
"journal.page.description": "Description",
@@ -1205,8 +1209,12 @@
"login.form.new-user": "New user? Click here to register.",
+ "login.form.or-divider": "or",
+
"login.form.password": "Password",
+ "login.form.shibboleth": "Log in with Shibboleth",
+
"login.form.submit": "Log in",
"login.title": "Login",
diff --git a/resources/i18n/pt.json5 b/resources/i18n/pt.json5
index 15f0608520..ba3d547546 100644
--- a/resources/i18n/pt.json5
+++ b/resources/i18n/pt.json5
@@ -918,7 +918,7 @@
"item.edit.move.processing": "Movendo...",
// "item.edit.move.search.placeholder": "Enter a search query to look for collections",
- "item.edit.move.search.placeholder": "nsira uma consulta para procurar coleções",
+ "item.edit.move.search.placeholder": "Insira uma consulta para procurar coleções",
// "item.edit.move.success": "The item has been moved successfully",
"item.edit.move.success": "O item foi movido com sucesso",
diff --git a/src/app/+collection-page/collection-page-routing.module.ts b/src/app/+collection-page/collection-page-routing.module.ts
index b51c74b457..589defa331 100644
--- a/src/app/+collection-page/collection-page-routing.module.ts
+++ b/src/app/+collection-page/collection-page-routing.module.ts
@@ -34,6 +34,11 @@ const COLLECTION_EDIT_PATH = 'edit';
@NgModule({
imports: [
RouterModule.forChild([
+ {
+ path: COLLECTION_CREATE_PATH,
+ component: CreateCollectionPageComponent,
+ canActivate: [AuthenticatedGuard, CreateCollectionPageGuard]
+ },
{
path: ':id',
resolve: {
@@ -66,11 +71,6 @@ const COLLECTION_EDIT_PATH = 'edit';
}
]
},
- {
- path: COLLECTION_CREATE_PATH,
- component: CreateCollectionPageComponent,
- canActivate: [AuthenticatedGuard, CreateCollectionPageGuard]
- },
])
],
providers: [
diff --git a/src/app/+community-page/community-page-routing.module.ts b/src/app/+community-page/community-page-routing.module.ts
index 0e2407577c..9922bc2c01 100644
--- a/src/app/+community-page/community-page-routing.module.ts
+++ b/src/app/+community-page/community-page-routing.module.ts
@@ -33,6 +33,11 @@ const COMMUNITY_EDIT_PATH = 'edit';
@NgModule({
imports: [
RouterModule.forChild([
+ {
+ path: COMMUNITY_CREATE_PATH,
+ component: CreateCommunityPageComponent,
+ canActivate: [AuthenticatedGuard, CreateCommunityPageGuard]
+ },
{
path: ':id',
resolve: {
@@ -59,11 +64,6 @@ const COMMUNITY_EDIT_PATH = 'edit';
}
]
},
- {
- path: COMMUNITY_CREATE_PATH,
- component: CreateCommunityPageComponent,
- canActivate: [AuthenticatedGuard, CreateCommunityPageGuard]
- },
])
],
providers: [
diff --git a/src/app/+item-page/full/full-item-page.component.html b/src/app/+item-page/full/full-item-page.component.html
index b93b8f1e12..29d3582492 100644
--- a/src/app/+item-page/full/full-item-page.component.html
+++ b/src/app/+item-page/full/full-item-page.component.html
@@ -1,6 +1,7 @@
+
diff --git a/src/app/+item-page/simple/item-page.component.html b/src/app/+item-page/simple/item-page.component.html
index 3b942f9d33..0e8abf0a02 100644
--- a/src/app/+item-page/simple/item-page.component.html
+++ b/src/app/+item-page/simple/item-page.component.html
@@ -1,6 +1,7 @@
+
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 8773b1a9fb..e5c9210769 100644
--- a/src/app/core/auth/auth-request.service.ts
+++ b/src/app/core/auth/auth-request.service.ts
@@ -10,6 +10,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 {
@@ -18,7 +19,8 @@ export class AuthRequestService {
constructor(@Inject(GLOBAL_CONFIG) protected EnvConfig: GlobalConfig,
protected halService: HALEndpointService,
- protected requestService: RequestService) {
+ protected requestService: RequestService,
+ private http: HttpClient) {
}
protected fetchRequest(request: RestRequest): Observable
{
@@ -38,7 +40,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)),
@@ -67,4 +69,5 @@ export class AuthRequestService {
mergeMap((request: GetRequest) => this.fetchRequest(request)),
distinctUntilChanged());
}
+
}
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 34b900fe7e..1f6fa51afd 100644
--- a/src/app/core/auth/auth.effects.spec.ts
+++ b/src/app/core/auth/auth.effects.spec.ts
@@ -14,19 +14,24 @@ 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 { AuthStatus } from './models/auth-status.model';
describe('AuthEffects', () => {
let authEffects: AuthEffects;
@@ -168,13 +173,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', () => {
@@ -231,6 +279,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', () => {
@@ -256,4 +336,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 f299696007..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 { 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 31649abe32..03759987bf 100644
--- a/src/app/core/auth/auth.service.spec.ts
+++ b/src/app/core/auth/auth.service.spec.ts
@@ -27,6 +27,8 @@ import { Observable } from 'rxjs/internal/Observable';
import { RemoteData } from '../data/remote-data';
import { createSuccessfulRemoteDataObject$ } from '../../shared/testing/utils';
import { EPersonDataService } from '../eperson/eperson-data.service';
+import { authMethodsMock } from '../../shared/testing/auth-service-stub';
+import { AuthMethod } from './models/auth.method';
describe('AuthService test', () => {
@@ -144,6 +146,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 66089d2928..04127ca9de 100644
--- a/src/app/core/core.module.ts
+++ b/src/app/core/core.module.ts
@@ -1,24 +1,19 @@
import { CommonModule } from '@angular/common';
import { HTTP_INTERCEPTORS, HttpClient } from '@angular/common/http';
import { ModuleWithProviders, NgModule, Optional, SkipSelf } from '@angular/core';
-import {
- DynamicFormLayoutService,
- DynamicFormService,
- DynamicFormValidationService
-} from '@ng-dynamic-forms/core';
-import { EffectsModule } from '@ngrx/effects';
+import { DynamicFormLayoutService, DynamicFormService, DynamicFormValidationService } from '@ng-dynamic-forms/core';
+import { EffectsModule } from '@ngrx/effects';
import { StoreModule } from '@ngrx/store';
+
import { MyDSpaceGuard } from '../+my-dspace-page/my-dspace.guard';
import { ENV_CONFIG, GLOBAL_CONFIG, GlobalConfig } from '../../config';
-
import { isNotEmpty } from '../shared/empty.util';
import { FormBuilderService } from '../shared/form/builder/form-builder.service';
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,
MockResponseMap,
@@ -48,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 } from './core.reducers';
import { BitstreamFormatDataService } from './data/bitstream-format-data.service';
@@ -103,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';
@@ -179,7 +172,11 @@ const PROVIDERS = [
SiteDataService,
DSOResponseParsingService,
{ provide: MOCK_RESPONSE_MAP, useValue: mockResponseMap },
- { provide: DSpaceRESTv2Service, useFactory: restServiceFactory, deps: [GLOBAL_CONFIG, MOCK_RESPONSE_MAP, HttpClient]},
+ {
+ provide: DSpaceRESTv2Service,
+ useFactory: restServiceFactory,
+ deps: [GLOBAL_CONFIG, MOCK_RESPONSE_MAP, HttpClient]
+ },
DynamicFormLayoutService,
DynamicFormService,
DynamicFormValidationService,
@@ -215,7 +212,6 @@ const PROVIDERS = [
BrowseItemsResponseParsingService,
BrowseService,
ConfigResponseParsingService,
- RouteService,
SubmissionDefinitionsConfigService,
SubmissionFormsConfigService,
SubmissionRestService,
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/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/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/comcol-forms/comcol-form/comcol-form.component.spec.ts b/src/app/shared/comcol-forms/comcol-form/comcol-form.component.spec.ts
index 06f9843c6d..454a036b15 100644
--- a/src/app/shared/comcol-forms/comcol-form/comcol-form.component.spec.ts
+++ b/src/app/shared/comcol-forms/comcol-form/comcol-form.component.spec.ts
@@ -137,7 +137,7 @@ describe('ComColFormComponent', () => {
type: Community.type
},
),
- uploader: {} as any,
+ uploader: undefined,
deleteLogo: false
}
);
diff --git a/src/app/shared/comcol-forms/comcol-form/comcol-form.component.ts b/src/app/shared/comcol-forms/comcol-form/comcol-form.component.ts
index 35c6f50969..f8199d2aad 100644
--- a/src/app/shared/comcol-forms/comcol-form/comcol-form.component.ts
+++ b/src/app/shared/comcol-forms/comcol-form/comcol-form.component.ts
@@ -39,7 +39,7 @@ export class ComColFormComponent
implements OnInit, OnDe
/**
* The logo uploader component
*/
- @ViewChild(UploaderComponent, {static: true}) uploaderComponent: UploaderComponent;
+ @ViewChild(UploaderComponent, {static: false}) uploaderComponent: UploaderComponent;
/**
* DSpaceObject that the form represents
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 @@
-