Merge branch 'master' into angular-cli

This commit is contained in:
lotte
2020-03-31 13:49:40 +02:00
72 changed files with 2709 additions and 415 deletions

View File

@@ -22,6 +22,7 @@ import { EditRelationshipComponent } from './item-relationships/edit-relationshi
import { EditRelationshipListComponent } from './item-relationships/edit-relationship-list/edit-relationship-list.component'; import { EditRelationshipListComponent } from './item-relationships/edit-relationship-list/edit-relationship-list.component';
import { ItemMoveComponent } from './item-move/item-move.component'; import { ItemMoveComponent } from './item-move/item-move.component';
import { VirtualMetadataComponent } from './virtual-metadata/virtual-metadata.component'; import { VirtualMetadataComponent } from './virtual-metadata/virtual-metadata.component';
import { ItemVersionHistoryComponent } from './item-version-history/item-version-history.component';
/** /**
* Module that contains all components related to the Edit Item page administrator functionality * Module that contains all components related to the Edit Item page administrator functionality
@@ -47,6 +48,7 @@ import { VirtualMetadataComponent } from './virtual-metadata/virtual-metadata.co
ItemMetadataComponent, ItemMetadataComponent,
ItemRelationshipsComponent, ItemRelationshipsComponent,
ItemBitstreamsComponent, ItemBitstreamsComponent,
ItemVersionHistoryComponent,
EditInPlaceFieldComponent, EditInPlaceFieldComponent,
EditRelationshipComponent, EditRelationshipComponent,
EditRelationshipListComponent, EditRelationshipListComponent,

View File

@@ -1,4 +1,3 @@
import { ItemPageResolver } from '../item-page.resolver';
import { NgModule } from '@angular/core'; import { NgModule } from '@angular/core';
import { RouterModule } from '@angular/router'; import { RouterModule } from '@angular/router';
import { EditItemPageComponent } from './edit-item-page.component'; import { EditItemPageComponent } from './edit-item-page.component';
@@ -14,6 +13,7 @@ import { ItemCollectionMapperComponent } from './item-collection-mapper/item-col
import { ItemMoveComponent } from './item-move/item-move.component'; import { ItemMoveComponent } from './item-move/item-move.component';
import { ItemRelationshipsComponent } from './item-relationships/item-relationships.component'; import { ItemRelationshipsComponent } from './item-relationships/item-relationships.component';
import { I18nBreadcrumbResolver } from '../../core/breadcrumbs/i18n-breadcrumb.resolver'; import { I18nBreadcrumbResolver } from '../../core/breadcrumbs/i18n-breadcrumb.resolver';
import { ItemVersionHistoryComponent } from './item-version-history/item-version-history.component';
export const ITEM_EDIT_WITHDRAW_PATH = 'withdraw'; export const ITEM_EDIT_WITHDRAW_PATH = 'withdraw';
export const ITEM_EDIT_REINSTATE_PATH = 'reinstate'; export const ITEM_EDIT_REINSTATE_PATH = 'reinstate';
@@ -75,6 +75,11 @@ export const ITEM_EDIT_MOVE_PATH = 'move';
/* TODO - change when curate page exists */ /* TODO - change when curate page exists */
component: ItemBitstreamsComponent, component: ItemBitstreamsComponent,
data: { title: 'item.edit.tabs.curate.title', showBreadcrumbs: true } data: { title: 'item.edit.tabs.curate.title', showBreadcrumbs: true }
},
{
path: 'versionhistory',
component: ItemVersionHistoryComponent,
data: { title: 'item.edit.tabs.versionhistory.title', showBreadcrumbs: true }
} }
] ]
}, },

View File

@@ -0,0 +1,6 @@
<div class="mt-4">
<ds-alert [content]="'item.edit.tabs.versionhistory.under-construction'" [type]="AlertTypeEnum.Warning"></ds-alert>
</div>
<div class="mt-2" *ngVar="(itemRD$ | async)?.payload as item">
<ds-item-versions *ngIf="item" [item]="item" [displayWhenEmpty]="true" [displayTitle]="false"></ds-item-versions>
</div>

View File

@@ -0,0 +1,44 @@
import { ItemVersionHistoryComponent } from './item-version-history.component';
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { VarDirective } from '../../../shared/utils/var.directive';
import { RouterTestingModule } from '@angular/router/testing';
import { TranslateModule } from '@ngx-translate/core';
import { NO_ERRORS_SCHEMA } from '@angular/core';
import { Item } from '../../../core/shared/item.model';
import { ActivatedRoute } from '@angular/router';
import { of as observableOf } from 'rxjs';
import { createSuccessfulRemoteDataObject } from '../../../shared/testing/utils';
describe('ItemVersionHistoryComponent', () => {
let component: ItemVersionHistoryComponent;
let fixture: ComponentFixture<ItemVersionHistoryComponent>;
const item = Object.assign(new Item(), {
uuid: 'item-identifier-1',
handle: '123456789/1',
});
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [ItemVersionHistoryComponent, VarDirective],
imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([])],
providers: [
{ provide: ActivatedRoute, useValue: { parent: { data: observableOf({ item: createSuccessfulRemoteDataObject(item) }) } } }
],
schemas: [NO_ERRORS_SCHEMA]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(ItemVersionHistoryComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should initialize the itemRD$ from the route\'s data', (done) => {
component.itemRD$.subscribe((itemRD) => {
expect(itemRD.payload).toBe(item);
done();
});
});
});

View File

@@ -0,0 +1,35 @@
import { Component } from '@angular/core';
import { Observable } from 'rxjs/internal/Observable';
import { RemoteData } from '../../../core/data/remote-data';
import { Item } from '../../../core/shared/item.model';
import { map } from 'rxjs/operators';
import { getSucceededRemoteData } from '../../../core/shared/operators';
import { ActivatedRoute } from '@angular/router';
import { AlertType } from '../../../shared/alert/aletr-type';
@Component({
selector: 'ds-item-version-history',
templateUrl: './item-version-history.component.html'
})
/**
* Component for listing and managing an item's version history
*/
export class ItemVersionHistoryComponent {
/**
* The item to display the version history for
*/
itemRD$: Observable<RemoteData<Item>>;
/**
* The AlertType enumeration
* @type {AlertType}
*/
AlertTypeEnum = AlertType;
constructor(private route: ActivatedRoute) {
}
ngOnInit(): void {
this.itemRD$ = this.route.parent.data.pipe(map((data) => data.item)).pipe(getSucceededRemoteData()) as Observable<RemoteData<Item>>;
}
}

View File

@@ -1,6 +1,7 @@
<div class="container" *ngVar="(itemRD$ | async) as itemRD"> <div class="container" *ngVar="(itemRD$ | async) as itemRD">
<div class="item-page" *ngIf="itemRD?.hasSucceeded" @fadeInOut> <div class="item-page" *ngIf="itemRD?.hasSucceeded" @fadeInOut>
<div *ngIf="itemRD?.payload as item"> <div *ngIf="itemRD?.payload as item">
<ds-item-versions-notice [item]="item"></ds-item-versions-notice>
<ds-view-tracker [object]="item"></ds-view-tracker> <ds-view-tracker [object]="item"></ds-view-tracker>
<ds-item-page-title-field [item]="item"></ds-item-page-title-field> <ds-item-page-title-field [item]="item"></ds-item-page-title-field>
<div class="simple-view-link my-3"> <div class="simple-view-link my-3">
@@ -21,6 +22,7 @@
</table> </table>
<ds-item-page-full-file-section [item]="item"></ds-item-page-full-file-section> <ds-item-page-full-file-section [item]="item"></ds-item-page-full-file-section>
<ds-item-page-collections [item]="item"></ds-item-page-collections> <ds-item-page-collections [item]="item"></ds-item-page-collections>
<ds-item-versions class="mt-2" [item]="item"></ds-item-versions>
</div> </div>
</div> </div>
<ds-error *ngIf="itemRD?.hasFailed" message="{{'error.item' | translate}}"></ds-error> <ds-error *ngIf="itemRD?.hasFailed" message="{{'error.item' | translate}}"></ds-error>

View File

@@ -59,7 +59,7 @@ import { AbstractIncrementalListComponent } from './simple/abstract-incremental-
MetadataRepresentationListComponent, MetadataRepresentationListComponent,
RelatedEntitiesSearchComponent, RelatedEntitiesSearchComponent,
TabbedRelatedEntitiesSearchComponent, TabbedRelatedEntitiesSearchComponent,
AbstractIncrementalListComponent AbstractIncrementalListComponent,
], ],
exports: [ exports: [
ItemComponent, ItemComponent,

View File

@@ -28,6 +28,7 @@ export class ItemPageResolver implements Resolve<RemoteData<Item>> {
followLink('owningCollection'), followLink('owningCollection'),
followLink('bundles'), followLink('bundles'),
followLink('relationships'), followLink('relationships'),
followLink('version', undefined, true, followLink('versionhistory')),
).pipe( ).pipe(
find((RD) => hasValue(RD.error) || RD.hasSucceeded), find((RD) => hasValue(RD.error) || RD.hasSucceeded),
); );

View File

@@ -1,8 +1,10 @@
<div class="container" *ngVar="(itemRD$ | async) as itemRD"> <div class="container" *ngVar="(itemRD$ | async) as itemRD">
<div class="item-page" *ngIf="itemRD?.hasSucceeded" @fadeInOut> <div class="item-page" *ngIf="itemRD?.hasSucceeded" @fadeInOut>
<div *ngIf="itemRD?.payload as item"> <div *ngIf="itemRD?.payload as item">
<ds-item-versions-notice [item]="item"></ds-item-versions-notice>
<ds-view-tracker [object]="item"></ds-view-tracker> <ds-view-tracker [object]="item"></ds-view-tracker>
<ds-listable-object-component-loader [object]="item" [viewMode]="viewMode"></ds-listable-object-component-loader> <ds-listable-object-component-loader [object]="item" [viewMode]="viewMode"></ds-listable-object-component-loader>
<ds-item-versions class="mt-2" [item]="item"></ds-item-versions>
</div> </div>
</div> </div>
<ds-error *ngIf="itemRD?.hasFailed" message="{{'error.item' | translate}}"></ds-error> <ds-error *ngIf="itemRD?.hasFailed" message="{{'error.item' | translate}}"></ds-error>

View File

@@ -63,16 +63,29 @@ export function getDSOPath(dso: DSpaceObject): string {
{ path: COMMUNITY_MODULE_PATH, loadChildren: './+community-page/community-page.module#CommunityPageModule' }, { path: COMMUNITY_MODULE_PATH, loadChildren: './+community-page/community-page.module#CommunityPageModule' },
{ path: COLLECTION_MODULE_PATH, loadChildren: './+collection-page/collection-page.module#CollectionPageModule' }, { path: COLLECTION_MODULE_PATH, loadChildren: './+collection-page/collection-page.module#CollectionPageModule' },
{ path: ITEM_MODULE_PATH, loadChildren: './+item-page/item-page.module#ItemPageModule' }, { 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: 'search', loadChildren: './+search-page/search-page.module#SearchPageModule' },
{ path: 'browse', loadChildren: './+browse-by/browse-by.module#BrowseByModule'}, { path: 'browse', loadChildren: './+browse-by/browse-by.module#BrowseByModule'},
{ path: ADMIN_MODULE_PATH, loadChildren: './+admin/admin.module#AdminModule', canActivate: [AuthenticatedGuard] }, { path: ADMIN_MODULE_PATH, loadChildren: './+admin/admin.module#AdminModule', canActivate: [AuthenticatedGuard] },
{ path: 'login', loadChildren: './+login-page/login-page.module#LoginPageModule' }, { path: 'login', loadChildren: './+login-page/login-page.module#LoginPageModule' },
{ path: 'logout', loadChildren: './+logout-page/logout-page.module#LogoutPageModule' }, { path: 'logout', loadChildren: './+logout-page/logout-page.module#LogoutPageModule' },
{ path: 'submit', loadChildren: './+submit-page/submit-page.module#SubmitPageModule' }, { 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: 'workspaceitems',
{ path: PROFILE_MODULE_PATH, loadChildren: './profile-page/profile-page.module#ProfilePageModule', canActivate: [AuthenticatedGuard] }, 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 }, { path: '**', pathMatch: 'full', component: PageNotFoundComponent },
], ],
{ {

View File

@@ -9,6 +9,7 @@ import { AuthGetRequest, AuthPostRequest, GetRequest, PostRequest, RestRequest }
import { AuthStatusResponse, ErrorResponse } from '../cache/response.models'; import { AuthStatusResponse, ErrorResponse } from '../cache/response.models';
import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service'; import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service';
import { getResponseFromEntry } from '../shared/operators'; import { getResponseFromEntry } from '../shared/operators';
import { HttpClient } from '@angular/common/http';
@Injectable() @Injectable()
export class AuthRequestService { export class AuthRequestService {
@@ -16,7 +17,8 @@ export class AuthRequestService {
protected browseEndpoint = ''; protected browseEndpoint = '';
constructor(protected halService: HALEndpointService, constructor(protected halService: HALEndpointService,
protected requestService: RequestService) { protected requestService: RequestService,
private http: HttpClient) {
} }
protected fetchRequest(request: RestRequest): Observable<any> { protected fetchRequest(request: RestRequest): Observable<any> {
@@ -36,7 +38,7 @@ export class AuthRequestService {
return isNotEmpty(method) ? `${endpoint}/${method}` : `${endpoint}`; return isNotEmpty(method) ? `${endpoint}/${method}` : `${endpoint}`;
} }
public postToEndpoint(method: string, body: any, options?: HttpOptions): Observable<any> { public postToEndpoint(method: string, body?: any, options?: HttpOptions): Observable<any> {
return this.halService.getEndpoint(this.linkName).pipe( return this.halService.getEndpoint(this.linkName).pipe(
filter((href: string) => isNotEmpty(href)), filter((href: string) => isNotEmpty(href)),
map((endpointURL) => this.getEndpointByMethod(endpointURL, method)), map((endpointURL) => this.getEndpointByMethod(endpointURL, method)),

View File

@@ -5,6 +5,8 @@ import { type } from '../../shared/ngrx/type';
// import models // import models
import { EPerson } from '../eperson/models/eperson.model'; import { EPerson } from '../eperson/models/eperson.model';
import { AuthTokenInfo } from './models/auth-token-info.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 = { export const AuthActionTypes = {
AUTHENTICATE: type('dspace/auth/AUTHENTICATE'), AUTHENTICATE: type('dspace/auth/AUTHENTICATE'),
@@ -14,12 +16,16 @@ export const AuthActionTypes = {
AUTHENTICATED_ERROR: type('dspace/auth/AUTHENTICATED_ERROR'), AUTHENTICATED_ERROR: type('dspace/auth/AUTHENTICATED_ERROR'),
AUTHENTICATED_SUCCESS: type('dspace/auth/AUTHENTICATED_SUCCESS'), AUTHENTICATED_SUCCESS: type('dspace/auth/AUTHENTICATED_SUCCESS'),
CHECK_AUTHENTICATION_TOKEN: type('dspace/auth/CHECK_AUTHENTICATION_TOKEN'), 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_TOKEN_EXPIRED: type('dspace/auth/REDIRECT_TOKEN_EXPIRED'),
REDIRECT_AUTHENTICATION_REQUIRED: type('dspace/auth/REDIRECT_AUTHENTICATION_REQUIRED'), REDIRECT_AUTHENTICATION_REQUIRED: type('dspace/auth/REDIRECT_AUTHENTICATION_REQUIRED'),
REFRESH_TOKEN: type('dspace/auth/REFRESH_TOKEN'), REFRESH_TOKEN: type('dspace/auth/REFRESH_TOKEN'),
REFRESH_TOKEN_SUCCESS: type('dspace/auth/REFRESH_TOKEN_SUCCESS'), REFRESH_TOKEN_SUCCESS: type('dspace/auth/REFRESH_TOKEN_SUCCESS'),
REFRESH_TOKEN_ERROR: type('dspace/auth/REFRESH_TOKEN_ERROR'), REFRESH_TOKEN_ERROR: type('dspace/auth/REFRESH_TOKEN_ERROR'),
RETRIEVE_TOKEN: type('dspace/auth/RETRIEVE_TOKEN'),
ADD_MESSAGE: type('dspace/auth/ADD_MESSAGE'), ADD_MESSAGE: type('dspace/auth/ADD_MESSAGE'),
RESET_MESSAGES: type('dspace/auth/RESET_MESSAGES'), RESET_MESSAGES: type('dspace/auth/RESET_MESSAGES'),
LOG_OUT: type('dspace/auth/LOG_OUT'), LOG_OUT: type('dspace/auth/LOG_OUT'),
@@ -95,7 +101,7 @@ export class AuthenticatedErrorAction implements Action {
payload: Error; payload: Error;
constructor(payload: Error) { constructor(payload: Error) {
this.payload = payload ; this.payload = payload;
} }
} }
@@ -109,7 +115,7 @@ export class AuthenticationErrorAction implements Action {
payload: Error; payload: Error;
constructor(payload: Error) { constructor(payload: Error) {
this.payload = payload ; this.payload = payload;
} }
} }
@@ -138,11 +144,11 @@ export class CheckAuthenticationTokenAction implements Action {
/** /**
* Check Authentication Token Error. * Check Authentication Token Error.
* @class CheckAuthenticationTokenErrorAction * @class CheckAuthenticationTokenCookieAction
* @implements {Action} * @implements {Action}
*/ */
export class CheckAuthenticationTokenErrorAction implements Action { export class CheckAuthenticationTokenCookieAction implements Action {
public type: string = AuthActionTypes.CHECK_AUTHENTICATION_TOKEN_ERROR; public type: string = AuthActionTypes.CHECK_AUTHENTICATION_TOKEN_COOKIE;
} }
/** /**
@@ -152,7 +158,9 @@ export class CheckAuthenticationTokenErrorAction implements Action {
*/ */
export class LogOutAction implements Action { export class LogOutAction implements Action {
public type: string = AuthActionTypes.LOG_OUT; 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; payload: Error;
constructor(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 { export class LogOutSuccessAction implements Action {
public type: string = AuthActionTypes.LOG_OUT_SUCCESS; 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; payload: string;
constructor(message: string) { constructor(message: string) {
this.payload = message ; this.payload = message;
} }
} }
@@ -203,7 +213,7 @@ export class RedirectWhenTokenExpiredAction implements Action {
payload: string; payload: string;
constructor(message: 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; 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. * Sign up.
* @class RegistrationAction * @class RegistrationAction
@@ -268,7 +287,7 @@ export class RegistrationErrorAction implements Action {
payload: Error; payload: Error;
constructor(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; 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. * Change the redirect url.
* @class SetRedirectUrlAction * @class SetRedirectUrlAction
@@ -319,7 +377,7 @@ export class SetRedirectUrlAction implements Action {
payload: string; payload: string;
constructor(url: string) { constructor(url: string) {
this.payload = url ; this.payload = url;
} }
} }
@@ -378,13 +436,21 @@ export type AuthActions
| AuthenticationErrorAction | AuthenticationErrorAction
| AuthenticationSuccessAction | AuthenticationSuccessAction
| CheckAuthenticationTokenAction | CheckAuthenticationTokenAction
| CheckAuthenticationTokenErrorAction | CheckAuthenticationTokenCookieAction
| RedirectWhenAuthenticationIsRequiredAction | RedirectWhenAuthenticationIsRequiredAction
| RedirectWhenTokenExpiredAction | RedirectWhenTokenExpiredAction
| RegistrationAction | RegistrationAction
| RegistrationErrorAction | RegistrationErrorAction
| RegistrationSuccessAction | RegistrationSuccessAction
| AddAuthenticationMessageAction | AddAuthenticationMessageAction
| RefreshTokenAction
| RefreshTokenErrorAction
| RefreshTokenSuccessAction
| ResetAuthenticationMessagesAction
| RetrieveAuthMethodsAction
| RetrieveAuthMethodsSuccessAction
| RetrieveAuthMethodsErrorAction
| RetrieveTokenAction
| ResetAuthenticationMessagesAction | ResetAuthenticationMessagesAction
| RetrieveAuthenticatedEpersonAction | RetrieveAuthenticatedEpersonAction
| RetrieveAuthenticatedEpersonErrorAction | RetrieveAuthenticatedEpersonErrorAction

View File

@@ -14,20 +14,26 @@ import {
AuthenticatedSuccessAction, AuthenticatedSuccessAction,
AuthenticationErrorAction, AuthenticationErrorAction,
AuthenticationSuccessAction, AuthenticationSuccessAction,
CheckAuthenticationTokenErrorAction, CheckAuthenticationTokenCookieAction,
LogOutErrorAction, LogOutErrorAction,
LogOutSuccessAction, LogOutSuccessAction,
RefreshTokenErrorAction, RefreshTokenErrorAction,
RefreshTokenSuccessAction, RefreshTokenSuccessAction,
RetrieveAuthenticatedEpersonAction, RetrieveAuthenticatedEpersonAction,
RetrieveAuthenticatedEpersonErrorAction, RetrieveAuthenticatedEpersonErrorAction,
RetrieveAuthenticatedEpersonSuccessAction RetrieveAuthenticatedEpersonSuccessAction,
RetrieveAuthMethodsAction,
RetrieveAuthMethodsErrorAction,
RetrieveAuthMethodsSuccessAction,
RetrieveTokenAction
} from './auth.actions'; } 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 { AuthService } from './auth.service';
import { AuthState } from './auth.reducer'; import { AuthState } from './auth.reducer';
import { EPersonMock } from '../../shared/testing/eperson.mock'; import { EPersonMock } from '../../shared/testing/eperson.mock';
import { EPersonMock } from '../../shared/testing/eperson-mock';
import { AuthStatus } from './models/auth-status.model';
describe('AuthEffects', () => { describe('AuthEffects', () => {
let authEffects: AuthEffects; let authEffects: AuthEffects;
@@ -169,13 +175,56 @@ describe('AuthEffects', () => {
actions = hot('--a-', { a: { type: AuthActionTypes.CHECK_AUTHENTICATION_TOKEN, payload: token } }); 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); 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('retrieveAuthenticatedEperson$', () => {
describe('when request is successful', () => { 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('logOut$', () => {
describe('when refresh token succeeded', () => { 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);
});
})
});
}); });

View File

@@ -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 { Injectable } from '@angular/core';
// import @ngrx // import @ngrx
@@ -9,6 +9,14 @@ import { Action, select, Store } from '@ngrx/store';
// import services // import services
import { AuthService } from './auth.service'; 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 actions
import { import {
AuthActionTypes, AuthActionTypes,
@@ -18,7 +26,7 @@ import {
AuthenticatedSuccessAction, AuthenticatedSuccessAction,
AuthenticationErrorAction, AuthenticationErrorAction,
AuthenticationSuccessAction, AuthenticationSuccessAction,
CheckAuthenticationTokenErrorAction, CheckAuthenticationTokenCookieAction,
LogOutErrorAction, LogOutErrorAction,
LogOutSuccessAction, LogOutSuccessAction,
RefreshTokenAction, RefreshTokenAction,
@@ -29,14 +37,12 @@ import {
RegistrationSuccessAction, RegistrationSuccessAction,
RetrieveAuthenticatedEpersonAction, RetrieveAuthenticatedEpersonAction,
RetrieveAuthenticatedEpersonErrorAction, RetrieveAuthenticatedEpersonErrorAction,
RetrieveAuthenticatedEpersonSuccessAction RetrieveAuthenticatedEpersonSuccessAction,
RetrieveAuthMethodsAction,
RetrieveAuthMethodsErrorAction,
RetrieveAuthMethodsSuccessAction,
RetrieveTokenAction
} from './auth.actions'; } 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() @Injectable()
export class AuthEffects { export class AuthEffects {
@@ -102,7 +108,24 @@ export class AuthEffects {
switchMap(() => { switchMap(() => {
return this.authService.hasValidAuthenticationToken().pipe( return this.authService.hasValidAuthenticationToken().pipe(
map((token: AuthTokenInfo) => new AuthenticatedAction(token)), map((token: AuthTokenInfo) => new AuthenticatedAction(token)),
catchError((error) => observableOf(new CheckAuthenticationTokenErrorAction())) catchError((error) => observableOf(new CheckAuthenticationTokenCookieAction()))
);
})
);
@Effect()
public checkTokenCookie$: Observable<Action> = 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)))
); );
}) })
); );
@@ -119,6 +142,18 @@ export class AuthEffects {
}) })
); );
@Effect()
public retrieveToken$: Observable<Action> = 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() @Effect()
public refreshToken$: Observable<Action> = this.actions$.pipe(ofType(AuthActionTypes.REFRESH_TOKEN), public refreshToken$: Observable<Action> = this.actions$.pipe(ofType(AuthActionTypes.REFRESH_TOKEN),
switchMap((action: RefreshTokenAction) => { switchMap((action: RefreshTokenAction) => {
@@ -188,6 +223,19 @@ export class AuthEffects {
tap(() => this.authService.redirectToLoginWhenTokenExpired()) tap(() => this.authService.redirectToLoginWhenTokenExpired())
); );
@Effect()
public retrieveMethods$: Observable<Action> = 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 * @constructor
* @param {Actions} actions$ * @param {Actions} actions$

View File

@@ -6,6 +6,7 @@ import {
HttpErrorResponse, HttpErrorResponse,
HttpEvent, HttpEvent,
HttpHandler, HttpHandler,
HttpHeaders,
HttpInterceptor, HttpInterceptor,
HttpRequest, HttpRequest,
HttpResponse, HttpResponse,
@@ -17,10 +18,12 @@ import { AppState } from '../../app.reducer';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
import { AuthStatus } from './models/auth-status.model'; import { AuthStatus } from './models/auth-status.model';
import { AuthTokenInfo } from './models/auth-token-info.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 { RedirectWhenTokenExpiredAction, RefreshTokenAction } from './auth.actions';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { AuthMethod } from './models/auth.method';
import { AuthMethodType } from './models/auth.method-type';
@Injectable() @Injectable()
export class AuthInterceptor implements HttpInterceptor { export class AuthInterceptor implements HttpInterceptor {
@@ -30,17 +33,33 @@ export class AuthInterceptor implements HttpInterceptor {
// we're creating a refresh token request list // we're creating a refresh token request list
protected refreshTokenRequestUrls = []; protected refreshTokenRequestUrls = [];
constructor(private inj: Injector, private router: Router, private store: Store<AppState>) { } constructor(private inj: Injector, private router: Router, private store: Store<AppState>) {
}
/**
* Check if response status code is 401
*
* @param response
*/
private isUnauthorized(response: HttpResponseBase): boolean { private isUnauthorized(response: HttpResponseBase): boolean {
// invalid_token The access token provided is expired, revoked, malformed, or invalid for other reasons // invalid_token The access token provided is expired, revoked, malformed, or invalid for other reasons
return response.status === 401; return response.status === 401;
} }
/**
* Check if response status code is 200 or 204
*
* @param response
*/
private isSuccess(response: HttpResponseBase): boolean { private isSuccess(response: HttpResponseBase): boolean {
return (response.status === 200 || response.status === 204); return (response.status === 200 || response.status === 204);
} }
/**
* Check if http request is to authn endpoint
*
* @param http
*/
private isAuthRequest(http: HttpRequest<any> | HttpResponseBase): boolean { private isAuthRequest(http: HttpRequest<any> | HttpResponseBase): boolean {
return http && http.url return http && http.url
&& (http.url.endsWith('/authn/login') && (http.url.endsWith('/authn/login')
@@ -48,18 +67,131 @@ export class AuthInterceptor implements HttpInterceptor {
|| http.url.endsWith('/authn/status')); || http.url.endsWith('/authn/status'));
} }
/**
* Check if response is from a login request
*
* @param http
*/
private isLoginResponse(http: HttpRequest<any> | HttpResponseBase): boolean { private isLoginResponse(http: HttpRequest<any> | 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<any> | HttpResponseBase): boolean { private isLogoutResponse(http: HttpRequest<any> | HttpResponseBase): boolean {
return http.url && http.url.endsWith('/authn/logout'); 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<any> | 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(); const authStatus = new AuthStatus();
// let authMethods: AuthMethodModel[];
if (httpHeaders) {
authStatus.authMethods = this.parseAuthMethodsFromHeaders(httpHeaders);
}
authStatus.id = null; authStatus.id = null;
authStatus.okay = true; authStatus.okay = true;
// authStatus.authMethods = authMethods;
if (authenticated) { if (authenticated) {
authStatus.authenticated = true; authStatus.authenticated = true;
authStatus.token = new AuthTokenInfo(accessToken); authStatus.token = new AuthTokenInfo(accessToken);
@@ -70,12 +202,18 @@ export class AuthInterceptor implements HttpInterceptor {
return authStatus; return authStatus;
} }
/**
* Intercept method
* @param req
* @param next
*/
intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> { intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
const authService = this.inj.get(AuthService); const authService = this.inj.get(AuthService);
const token = authService.getToken(); const token: AuthTokenInfo = authService.getToken();
let newReq; let newReq: HttpRequest<any>;
let authorization: string;
if (authService.isTokenExpired()) { if (authService.isTokenExpired()) {
authService.setRedirectUrl(this.router.url); authService.setRedirectUrl(this.router.url);
@@ -96,30 +234,41 @@ export class AuthInterceptor implements HttpInterceptor {
} }
}); });
// Get the auth header from the service. // Get the auth header from the service.
const Authorization = authService.buildAuthHeader(token); authorization = authService.buildAuthHeader(token);
// Clone the request to add the new header. // 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 { } else {
newReq = req; newReq = req.clone();
} }
// Pass on the new request instead of the original request. // Pass on the new request instead of the original request.
return next.handle(newReq).pipe( return next.handle(newReq).pipe(
// tap((response) => console.log('next.handle: ', response)),
map((response) => { map((response) => {
// Intercept a Login/Logout 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 // It's a success Login/Logout response
let authRes: HttpResponse<any>; let authRes: HttpResponse<any>;
if (this.isLoginResponse(response)) { if (this.isLoginResponse(response)) {
// login successfully // login successfully
const newToken = response.headers.get('authorization'); 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 // clean eventually refresh Requests list
this.refreshTokenRequestUrls = []; this.refreshTokenRequestUrls = [];
} else if (this.isStatusResponse(response)) {
authRes = response.clone({
body: Object.assign(response.body, {
authMethods: this.parseAuthMethodsFromHeaders(response.headers)
})
})
} else { } else {
// logout successfully // logout successfully
authRes = response.clone({body: this.makeAuthStatusObject(false)}); authRes = response.clone({
body: this.makeAuthStatusObject(false)
});
} }
return authRes; return authRes;
} else { } else {
@@ -129,13 +278,15 @@ export class AuthInterceptor implements HttpInterceptor {
catchError((error, caught) => { catchError((error, caught) => {
// Intercept an error response // Intercept an error response
if (error instanceof HttpErrorResponse) { if (error instanceof HttpErrorResponse) {
// Checks if is a response from a request to an authentication endpoint // Checks if is a response from a request to an authentication endpoint
if (this.isAuthRequest(error)) { if (this.isAuthRequest(error)) {
// clean eventually refresh Requests list // clean eventually refresh Requests list
this.refreshTokenRequestUrls = []; this.refreshTokenRequestUrls = [];
// Create a new HttpResponse and return it, so it can be handle properly by AuthService. // Create a new HttpResponse and return it, so it can be handle properly by AuthService.
const authResponse = new HttpResponse({ const authResponse = new HttpResponse({
body: this.makeAuthStatusObject(false, null, error.error), body: this.makeAuthStatusObject(false, null, error.error, error.headers),
headers: error.headers, headers: error.headers,
status: error.status, status: error.status,
statusText: error.statusText, statusText: error.statusText,

View File

@@ -8,7 +8,7 @@ import {
AuthenticationErrorAction, AuthenticationErrorAction,
AuthenticationSuccessAction, AuthenticationSuccessAction,
CheckAuthenticationTokenAction, CheckAuthenticationTokenAction,
CheckAuthenticationTokenErrorAction, CheckAuthenticationTokenCookieAction,
LogOutAction, LogOutAction,
LogOutErrorAction, LogOutErrorAction,
LogOutSuccessAction, LogOutSuccessAction,
@@ -17,11 +17,19 @@ import {
RefreshTokenAction, RefreshTokenAction,
RefreshTokenErrorAction, RefreshTokenErrorAction,
RefreshTokenSuccessAction, RefreshTokenSuccessAction,
ResetAuthenticationMessagesAction, RetrieveAuthenticatedEpersonErrorAction, RetrieveAuthenticatedEpersonSuccessAction, ResetAuthenticationMessagesAction,
RetrieveAuthenticatedEpersonErrorAction,
RetrieveAuthenticatedEpersonSuccessAction,
RetrieveAuthMethodsAction,
RetrieveAuthMethodsErrorAction,
RetrieveAuthMethodsSuccessAction,
SetRedirectUrlAction SetRedirectUrlAction
} from './auth.actions'; } from './auth.actions';
import { AuthTokenInfo } from './models/auth-token-info.model'; 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', () => { describe('authReducer', () => {
@@ -157,18 +165,18 @@ describe('authReducer', () => {
expect(newState).toEqual(state); 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 = { initialState = {
authenticated: false, authenticated: false,
loaded: false, loaded: false,
loading: true, loading: true,
}; };
const action = new CheckAuthenticationTokenErrorAction(); const action = new CheckAuthenticationTokenCookieAction();
const newState = authReducer(initialState, action); const newState = authReducer(initialState, action);
state = { state = {
authenticated: false, authenticated: false,
loaded: false, loaded: false,
loading: false, loading: true,
}; };
expect(newState).toEqual(state); expect(newState).toEqual(state);
}); });
@@ -451,4 +459,63 @@ describe('authReducer', () => {
}; };
expect(newState).toEqual(state); 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);
});
}); });

View File

@@ -8,12 +8,16 @@ import {
LogOutErrorAction, LogOutErrorAction,
RedirectWhenAuthenticationIsRequiredAction, RedirectWhenAuthenticationIsRequiredAction,
RedirectWhenTokenExpiredAction, RedirectWhenTokenExpiredAction,
RefreshTokenSuccessAction, RetrieveAuthenticatedEpersonSuccessAction, RefreshTokenSuccessAction,
RetrieveAuthenticatedEpersonSuccessAction,
RetrieveAuthMethodsSuccessAction,
SetRedirectUrlAction SetRedirectUrlAction
} from './auth.actions'; } from './auth.actions';
// import models // import models
import { EPerson } from '../eperson/models/eperson.model'; import { EPerson } from '../eperson/models/eperson.model';
import { AuthTokenInfo } from './models/auth-token-info.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. * The auth state.
@@ -47,6 +51,10 @@ export interface AuthState {
// the authenticated user // the authenticated user
user?: EPerson; user?: EPerson;
// all authentication Methods enabled at the backend
authMethods?: AuthMethod[];
} }
/** /**
@@ -56,6 +64,7 @@ const initialState: AuthState = {
authenticated: false, authenticated: false,
loaded: false, loaded: false,
loading: false, loading: false,
authMethods: []
}; };
/** /**
@@ -75,6 +84,8 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut
}); });
case AuthActionTypes.AUTHENTICATED: case AuthActionTypes.AUTHENTICATED:
case AuthActionTypes.CHECK_AUTHENTICATION_TOKEN:
case AuthActionTypes.CHECK_AUTHENTICATION_TOKEN_COOKIE:
return Object.assign({}, state, { return Object.assign({}, state, {
loading: true loading: true
}); });
@@ -113,21 +124,10 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut
loading: false loading: false
}); });
case AuthActionTypes.AUTHENTICATED:
case AuthActionTypes.AUTHENTICATE_SUCCESS: case AuthActionTypes.AUTHENTICATE_SUCCESS:
case AuthActionTypes.LOG_OUT: case AuthActionTypes.LOG_OUT:
return state; 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: case AuthActionTypes.LOG_OUT_ERROR:
return Object.assign({}, state, { return Object.assign({}, state, {
authenticated: true, authenticated: true,
@@ -192,6 +192,24 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut
info: undefined, 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: case AuthActionTypes.SET_REDIRECT_URL:
return Object.assign({}, state, { return Object.assign({}, state, {
redirectUrl: (action as SetRedirectUrlAction).payload, redirectUrl: (action as SetRedirectUrlAction).payload,

View File

@@ -28,6 +28,8 @@ import { Observable } from 'rxjs/internal/Observable';
import { RemoteData } from '../data/remote-data'; import { RemoteData } from '../data/remote-data';
import { EPersonDataService } from '../eperson/eperson-data.service'; import { EPersonDataService } from '../eperson/eperson-data.service';
import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
import { authMethodsMock } from '../../shared/testing/auth-service.stub';
import { AuthMethod } from './models/auth.method';
describe('AuthService test', () => { describe('AuthService test', () => {
@@ -150,6 +152,26 @@ describe('AuthService test', () => {
expect(authService.logout.bind(null)).toThrow(); 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('', () => { describe('', () => {

View File

@@ -18,16 +18,20 @@ import { isEmpty, isNotEmpty, isNotNull, isNotUndefined } from '../../shared/emp
import { CookieService } from '../services/cookie.service'; import { CookieService } from '../services/cookie.service';
import { getAuthenticationToken, getRedirectUrl, isAuthenticated, isTokenRefreshing } from './selectors'; import { getAuthenticationToken, getRedirectUrl, isAuthenticated, isTokenRefreshing } from './selectors';
import { AppState, routerStateSelector } from '../../app.reducer'; 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 { NativeWindowRef, NativeWindowService } from '../services/window.service';
import { Base64EncodeUrl } from '../../shared/utils/encode-decode.util'; import { Base64EncodeUrl } from '../../shared/utils/encode-decode.util';
import { RouteService } from '../services/route.service'; import { RouteService } from '../services/route.service';
import { EPersonDataService } from '../eperson/eperson-data.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 LOGIN_ROUTE = '/login';
export const LOGOUT_ROUTE = '/logout'; export const LOGOUT_ROUTE = '/logout';
export const REDIRECT_COOKIE = 'dsRedirectUrl'; export const REDIRECT_COOKIE = 'dsRedirectUrl';
/** /**
@@ -114,6 +118,21 @@ export class AuthService {
} }
/**
* Checks if token is present into the request cookie
*/
public checkAuthenticationCookie(): Observable<AuthStatus> {
// 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 * Determines if the user is authenticated
* @returns {Observable<boolean>} * @returns {Observable<boolean>}
@@ -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() { public checkAuthenticationToken() {
return this.store.dispatch(new CheckAuthenticationTokenAction());
} }
/** /**
@@ -187,8 +206,11 @@ export class AuthService {
const options: HttpOptions = Object.create({}); const options: HttpOptions = Object.create({});
let headers = new HttpHeaders(); let headers = new HttpHeaders();
headers = headers.append('Accept', 'application/json'); headers = headers.append('Accept', 'application/json');
if (token && token.accessToken) {
headers = headers.append('Authorization', `Bearer ${token.accessToken}`); headers = headers.append('Authorization', `Bearer ${token.accessToken}`);
}
options.headers = headers; options.headers = headers;
options.withCredentials = true;
return this.authRequestService.postToEndpoint('login', {}, options).pipe( return this.authRequestService.postToEndpoint('login', {}, options).pipe(
map((status: AuthStatus) => { map((status: AuthStatus) => {
if (status.authenticated) { if (status.authenticated) {
@@ -206,6 +228,18 @@ export class AuthService {
this.store.dispatch(new ResetAuthenticationMessagesAction()); this.store.dispatch(new ResetAuthenticationMessagesAction());
} }
/**
* Retrieve authentication methods available
* @returns {User}
*/
public retrieveAuthMethodsFromAuthStatus(status: AuthStatus): Observable<AuthMethod[]> {
let authMethods: AuthMethod[] = [];
if (isNotEmpty(status.authMethods)) {
authMethods = status.authMethods;
}
return observableOf(authMethods);
}
/** /**
* Create a new user * Create a new user
* @returns {User} * @returns {User}

View File

@@ -1,17 +1,14 @@
import {take} from 'rxjs/operators';
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, CanActivate, CanLoad, Route, Router, RouterStateSnapshot } from '@angular/router'; 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'; import { select, Store } from '@ngrx/store';
// reducers
import { CoreState } from '../core.reducers'; import { CoreState } from '../core.reducers';
import { isAuthenticated, isAuthenticationLoading } from './selectors'; import { isAuthenticated } from './selectors';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
import { RedirectWhenAuthenticationIsRequiredAction } from './auth.actions'; import { RedirectWhenAuthenticationIsRequiredAction } from './auth.actions';
import { isEmpty } from '../../shared/empty.util';
/** /**
* Prevent unauthorized activating and loading of routes * Prevent unauthorized activating and loading of routes

View File

@@ -12,6 +12,7 @@ import { excludeFromEquals } from '../../utilities/equals.decorators';
import { AuthError } from './auth-error.model'; import { AuthError } from './auth-error.model';
import { AUTH_STATUS } from './auth-status.resource-type'; import { AUTH_STATUS } from './auth-status.resource-type';
import { AuthTokenInfo } from './auth-token-info.model'; import { AuthTokenInfo } from './auth-token-info.model';
import { AuthMethod } from './auth.method';
/** /**
* Object that represents the authenticated status of a user * 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 * Authentication error if there was one for this status
*/ */
// TODO should be refactored to use the RemoteData error // TODO should be refactored to use the RemoteData error
@autoserialize
error?: AuthError; error?: AuthError;
/**
* All authentication methods enabled at the backend
*/
@autoserialize
authMethods: AuthMethod[];
} }

View File

@@ -0,0 +1,7 @@
export enum AuthMethodType {
Password = 'password',
Shibboleth = 'shibboleth',
Ldap = 'ldap',
Ip = 'ip',
X509 = 'x509'
}

View File

@@ -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;
}
}
}
}

View File

@@ -107,6 +107,17 @@ const _getRegistrationError = (state: AuthState) => state.error;
*/ */
const _getRedirectUrl = (state: AuthState) => state.redirectUrl; 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 * Returns the authenticated user
* @function getAuthenticatedUser * @function getAuthenticatedUser

View File

@@ -1,11 +1,11 @@
import { HttpHeaders } from '@angular/common/http';
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { HttpHeaders } from '@angular/common/http';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { filter, map, take } from 'rxjs/operators'; import { filter, map, take } from 'rxjs/operators';
import { isNotEmpty } from '../../shared/empty.util'; import { isNotEmpty } from '../../shared/empty.util';
import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service'; import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service';
import { CheckAuthenticationTokenAction } from './auth.actions';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
import { AuthStatus } from './models/auth-status.model'; import { AuthStatus } from './models/auth-status.model';
import { AuthTokenInfo } from './models/auth-token-info.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() { public checkAuthenticationCookie(): Observable<AuthStatus> {
this.store.dispatch(new CheckAuthenticationTokenAction()) // 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))
);
} }
/** /**

View File

@@ -1,11 +1,8 @@
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { HTTP_INTERCEPTORS, HttpClient } from '@angular/common/http'; import { HTTP_INTERCEPTORS, HttpClient } from '@angular/common/http';
import { ModuleWithProviders, NgModule, Optional, SkipSelf } from '@angular/core'; import { ModuleWithProviders, NgModule, Optional, SkipSelf } from '@angular/core';
import {
DynamicFormLayoutService, import { DynamicFormLayoutService, DynamicFormService, DynamicFormValidationService } from '@ng-dynamic-forms/core';
DynamicFormService,
DynamicFormValidationService
} from '@ng-dynamic-forms/core';
import { EffectsModule } from '@ngrx/effects'; import { EffectsModule } from '@ngrx/effects';
import { Action, StoreConfig, StoreModule } from '@ngrx/store'; 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 { HostWindowService } from '../shared/host-window.service';
import { MenuService } from '../shared/menu/menu.service'; import { MenuService } from '../shared/menu/menu.service';
import { EndpointMockingRestService } from '../shared/mocks/dspace-rest-v2/endpoint-mocking-rest.service'; import { EndpointMockingRestService } from '../shared/mocks/dspace-rest-v2/endpoint-mocking-rest.service';
import { import {
MOCK_RESPONSE_MAP, MOCK_RESPONSE_MAP,
ResponseMapMock, ResponseMapMock,
@@ -47,7 +43,6 @@ import { SubmissionUploadsModel } from './config/models/config-submission-upload
import { SubmissionDefinitionsConfigService } from './config/submission-definitions-config.service'; import { SubmissionDefinitionsConfigService } from './config/submission-definitions-config.service';
import { SubmissionFormsConfigService } from './config/submission-forms-config.service'; import { SubmissionFormsConfigService } from './config/submission-forms-config.service';
import { SubmissionSectionsConfigService } from './config/submission-sections-config.service'; import { SubmissionSectionsConfigService } from './config/submission-sections-config.service';
import { coreEffects } from './core.effects'; import { coreEffects } from './core.effects';
import { coreReducers, CoreState } from './core.reducers'; import { coreReducers, CoreState } from './core.reducers';
import { BitstreamFormatDataService } from './data/bitstream-format-data.service'; 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 { RoleService } from './roles/role.service';
import { ApiService } from './services/api.service'; import { ApiService } from './services/api.service';
import { RouteService } from './services/route.service';
import { ServerResponseService } from './services/server-response.service'; import { ServerResponseService } from './services/server-response.service';
import { NativeWindowFactory, NativeWindowService } from './services/window.service'; import { NativeWindowFactory, NativeWindowService } from './services/window.service';
import { BitstreamFormat } from './shared/bitstream-format.model'; 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 { TaskResponseParsingService } from './tasks/task-response-parsing.service';
import { environment } from '../../environments/environment'; import { environment } from '../../environments/environment';
import { storeModuleConfig } from '../app.reducer'; 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 * When not in production, endpoint responses can be mocked for testing purposes
@@ -212,7 +210,6 @@ const PROVIDERS = [
BrowseItemsResponseParsingService, BrowseItemsResponseParsingService,
BrowseService, BrowseService,
ConfigResponseParsingService, ConfigResponseParsingService,
RouteService,
SubmissionDefinitionsConfigService, SubmissionDefinitionsConfigService,
SubmissionFormsConfigService, SubmissionFormsConfigService,
SubmissionRestService, SubmissionRestService,
@@ -256,6 +253,8 @@ const PROVIDERS = [
RelationshipTypeService, RelationshipTypeService,
ExternalSourceService, ExternalSourceService,
LookupRelationService, LookupRelationService,
VersionDataService,
VersionHistoryDataService,
LicenseDataService, LicenseDataService,
ItemTypeDataService, ItemTypeDataService,
// register AuthInterceptor as HttpInterceptor // register AuthInterceptor as HttpInterceptor
@@ -306,6 +305,8 @@ export const models =
ItemType, ItemType,
ExternalSource, ExternalSource,
ExternalSourceEntry, ExternalSourceEntry,
Version,
VersionHistory
]; ];
@NgModule({ @NgModule({

View File

@@ -230,6 +230,8 @@ export class AuthPostRequest extends PostRequest {
} }
export class AuthGetRequest extends GetRequest { export class AuthGetRequest extends GetRequest {
forceBypassCache = true;
constructor(uuid: string, href: string, public options?: HttpOptions) { constructor(uuid: string, href: string, public options?: HttpOptions) {
super(uuid, href, null, options); super(uuid, href, null, options);
} }

View File

@@ -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<Version> {
protected linkPath = 'versions';
constructor(
protected requestService: RequestService,
protected rdbService: RemoteDataBuildService,
protected store: Store<CoreState>,
protected objectCache: ObjectCacheService,
protected halService: HALEndpointService,
protected notificationsService: NotificationsService,
protected http: HttpClient,
protected comparator: DefaultChangeAnalyzer<Version>) {
super();
}
/**
* Get the endpoint for browsing versions
*/
getBrowseEndpoint(options: FindListOptions = {}, linkPath?: string): Observable<string> {
return this.halService.getEndpoint(this.linkPath);
}
}

View File

@@ -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);
}
});

View File

@@ -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<VersionHistory> {
protected linkPath = 'versionhistories';
protected versionsEndpoint = 'versions';
constructor(
protected requestService: RequestService,
protected rdbService: RemoteDataBuildService,
protected store: Store<CoreState>,
protected objectCache: ObjectCacheService,
protected halService: HALEndpointService,
protected notificationsService: NotificationsService,
protected http: HttpClient,
protected comparator: DefaultChangeAnalyzer<VersionHistory>) {
super();
}
/**
* Get the endpoint for browsing versions
*/
getBrowseEndpoint(options: FindListOptions = {}, linkPath?: string): Observable<string> {
return this.halService.getEndpoint(this.linkPath);
}
/**
* Get the versions endpoint for a version history
* @param versionHistoryId
*/
getVersionsEndpoint(versionHistoryId: string): Observable<string> {
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<FollowLinkConfig<Version>>): Observable<RemoteData<PaginatedList<Version>>> {
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<Version>(hrefObs, ...linksToFollow);
}
}

View File

@@ -90,6 +90,14 @@ export class DSpaceRESTv2Service {
requestOptions.headers = options.headers; 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')) { if (!requestOptions.headers.has('Content-Type')) {
// Because HttpHeaders is immutable, the set method returns a new object instead of updating the existing headers // 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); requestOptions.headers = requestOptions.headers.set('Content-Type', DEFAULT_CONTENT_TYPE);

View File

@@ -59,7 +59,9 @@ export function parameterSelector(key: string, paramsSelector: (state: CoreState
/** /**
* Service to keep track of the current query parameters * Service to keep track of the current query parameters
*/ */
@Injectable() @Injectable({
providedIn: 'root'
})
export class RouteService { export class RouteService {
constructor(private route: ActivatedRoute, private router: Router, private store: Store<CoreState>) { constructor(private route: ActivatedRoute, private router: Router, private store: Store<CoreState>) {
this.saveRouting(); this.saveRouting();

View File

@@ -18,6 +18,8 @@ import { Relationship } from './item-relationships/relationship.model';
import { RELATIONSHIP } from './item-relationships/relationship.resource-type'; import { RELATIONSHIP } from './item-relationships/relationship.resource-type';
import { ITEM } from './item.resource-type'; import { ITEM } from './item.resource-type';
import { ChildHALResource } from './child-hal-resource.model'; import { ChildHALResource } from './child-hal-resource.model';
import { Version } from './version.model';
import { VERSION } from './version.resource-type';
/** /**
* Class representing a DSpace Item * Class representing a DSpace Item
@@ -67,6 +69,7 @@ export class Item extends DSpaceObject implements ChildHALResource {
bundles: HALLink; bundles: HALLink;
owningCollection: HALLink; owningCollection: HALLink;
templateItemOf: HALLink; templateItemOf: HALLink;
version: HALLink;
self: HALLink; self: HALLink;
}; };
@@ -77,6 +80,13 @@ export class Item extends DSpaceObject implements ChildHALResource {
@link(COLLECTION) @link(COLLECTION)
owningCollection?: Observable<RemoteData<Collection>>; owningCollection?: Observable<RemoteData<Collection>>;
/**
* The version this item represents in its history
* Will be undefined unless the version {@link HALLink} has been resolved.
*/
@link(VERSION)
version?: Observable<RemoteData<Version>>;
/** /**
* The list of Bundles inside this Item * The list of Bundles inside this Item
* Will be undefined unless the bundles {@link HALLink} has been resolved. * Will be undefined unless the bundles {@link HALLink} has been resolved.

View File

@@ -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<RemoteData<PaginatedList<Version>>>;
}

View File

@@ -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');

View File

@@ -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<RemoteData<VersionHistory>>;
/**
* The item this version represents
*/
@excludeFromEquals
@link(ITEM)
item: Observable<RemoteData<Item>>;
/**
* The e-person who created this version
*/
@excludeFromEquals
@link(EPERSON)
eperson: Observable<RemoteData<EPerson>>;
}

View File

@@ -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');

View File

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

View File

@@ -0,0 +1,47 @@
<div *ngVar="(versionsRD$ | async)?.payload as versions">
<div *ngVar="(versionRD$ | async)?.payload as itemVersion">
<div class="mb-2" *ngIf="versions?.page?.length > 0 || displayWhenEmpty">
<h2 *ngIf="displayTitle">{{"item.version.history.head" | translate}}</h2>
<ds-pagination *ngIf="versions?.page?.length > 0"
[hideGear]="true"
[hidePagerWhenSinglePage]="true"
[paginationOptions]="options"
[pageInfoState]="versions"
[collectionSize]="versions?.totalElements"
[disableRouteParameterUpdate]="true"
(pageChange)="switchPage($event)">
<table class="table table-striped my-2">
<thead>
<tr>
<th scope="col">{{"item.version.history.table.version" | translate}}</th>
<th scope="col">{{"item.version.history.table.item" | translate}}</th>
<th scope="col" *ngIf="(hasEpersons$ | async)">{{"item.version.history.table.editor" | translate}}</th>
<th scope="col">{{"item.version.history.table.date" | translate}}</th>
<th scope="col">{{"item.version.history.table.summary" | translate}}</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let version of versions?.page" [id]="'version-row-' + version.id">
<td class="version-row-element-version">{{version?.version}}</td>
<td class="version-row-element-item">
<span *ngVar="(version?.item | async)?.payload as item">
<a *ngIf="item" [routerLink]="['/items', item?.id]">{{item?.handle}}</a>
<span *ngIf="version?.id === itemVersion?.id">*</span>
</span>
</td>
<td *ngIf="(hasEpersons$ | async)" class="version-row-element-editor">
<span *ngVar="(version?.eperson | async)?.payload as eperson">
<a *ngIf="eperson" [href]="'mailto:' + eperson?.email">{{eperson?.name}}</a>
</span>
</td>
<td class="version-row-element-date">{{version?.created}}</td>
<td class="version-row-element-summary">{{version?.summary}}</td>
</tr>
</tbody>
</table>
<div>*&nbsp;{{"item.version.history.selected" | translate}}</div>
</ds-pagination>
<ds-alert *ngIf="!itemVersion || versions?.page?.length === 0" [content]="'item.version.history.empty'" [type]="AlertTypeEnum.Info"></ds-alert>
</div>
</div>
</div>

View File

@@ -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<ItemVersionsComponent>;
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);
});
});
});

View File

@@ -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<RemoteData<Version>>;
/**
* The item's full version history
*/
versionHistoryRD$: Observable<RemoteData<VersionHistory>>;
/**
* The version history's list of versions
*/
versionsRD$: Observable<RemoteData<PaginatedList<Version>>>;
/**
* 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<boolean>;
/**
* 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<number>(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<Version>) => 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);
}
}

View File

@@ -0,0 +1,5 @@
<ds-alert *ngIf="isLatestVersion$ && !(isLatestVersion$ | async)"
[content]="('item.version.notice' | translate:{ destination: getItemPage(((latestVersion$ | async)?.item | async)?.payload) })"
[dismissible]="false"
[type]="AlertTypeEnum.Warning">
</ds-alert>

View File

@@ -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<ItemVersionsNoticeComponent>;
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();
}
});

View File

@@ -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<RemoteData<Version>>;
/**
* The item's full version history
*/
versionHistoryRD$: Observable<RemoteData<VersionHistory>>;
/**
* The latest version of the item's version history
*/
latestVersion$: Observable<Version>;
/**
* 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<boolean>;
/**
* 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);
}
}
}

View File

@@ -0,0 +1,5 @@
<ng-container
*ngComponentOutlet="getAuthMethodContent();
injector: objectInjector;">
</ng-container>

View File

@@ -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%;
}

View File

@@ -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<LogInContainerComponent>;
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<TestComponent>;
// synchronous beforeEach
beforeEach(() => {
const html = `<ds-log-in-container [authMethod]="authMethod"> </ds-log-in-container>`;
testFixture = createTestComponent(html, TestComponent) as ComponentFixture<TestComponent>;
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;
}

View File

@@ -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)
}
}

View File

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

View File

@@ -1,13 +1,3 @@
.form-login .form-control:focus { .login-container {
z-index: 2; max-width: 350px;
} }
.form-login input[type="email"] {
margin-bottom: -1px;
border-bottom-right-radius: 0;
border-bottom-left-radius: 0;
}
.form-login input[type="password"] {
border-top-left-radius: 0;
border-top-right-radius: 0;
}

View File

@@ -1,7 +1,5 @@
import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; import { Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import { async, ComponentFixture, inject, TestBed } from '@angular/core/testing'; import { async, ComponentFixture, inject, TestBed } from '@angular/core/testing';
import { FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms';
import { By } from '@angular/platform-browser'; import { By } from '@angular/platform-browser';
import { Store, StoreModule } from '@ngrx/store'; import { Store, StoreModule } from '@ngrx/store';
@@ -10,27 +8,27 @@ import { authReducer } from '../../core/auth/auth.reducer';
import { EPersonMock } from '../testing/eperson.mock'; import { EPersonMock } from '../testing/eperson.mock';
import { EPerson } from '../../core/eperson/models/eperson.model'; import { EPerson } from '../../core/eperson/models/eperson.model';
import { TranslateModule } from '@ngx-translate/core'; import { TranslateModule } from '@ngx-translate/core';
import { LogInComponent } from './log-in.component';
import { AuthService } from '../../core/auth/auth.service'; import { AuthService } from '../../core/auth/auth.service';
import { AuthServiceStub } from '../testing/auth-service.stub'; import { authMethodsMock, AuthServiceStub } from '../testing/auth-service.stub';
import { AppState, storeModuleConfig } from '../../app.reducer'; import { AppState, storeModuleConfig } from '../../app.reducer';
describe('LogInComponent', () => { describe('LogInComponent', () => {
let component: LogInComponent; let component: LogInComponent;
let fixture: ComponentFixture<LogInComponent>; let fixture: ComponentFixture<LogInComponent>;
let page: Page; const initialState = {
let user: EPerson; core: {
auth: {
const authState = {
authenticated: false, authenticated: false,
loaded: false, loaded: false,
loading: false, loading: false,
authMethods: authMethodsMock
}
}
}; };
beforeEach(() => {
user = EPersonMock;
});
beforeEach(async(() => { beforeEach(async(() => {
// refine the test module by declaring the test component // refine the test module by declaring the test component
TestBed.configureTestingModule({ TestBed.configureTestingModule({
@@ -43,13 +41,19 @@ describe('LogInComponent', () => {
strictActionImmutability: false strictActionImmutability: false
} }
}), }),
SharedModule,
TranslateModule.forRoot() TranslateModule.forRoot()
], ],
declarations: [ declarations: [
LogInComponent TestComponent
], ],
providers: [ providers: [
{provide: AuthService, useClass: AuthServiceStub} { provide: AuthService, useClass: AuthServiceStub },
{ provide: NativeWindowService, useFactory: NativeWindowMockFactory },
{ provide: Router, useValue: new RouterStub() },
{ provide: ActivatedRoute, useValue: new ActivatedRouteStub() },
provideMockStore({ initialState }),
LogInComponent
], ],
schemas: [ schemas: [
CUSTOM_ELEMENTS_SCHEMA CUSTOM_ELEMENTS_SCHEMA
@@ -59,75 +63,58 @@ describe('LogInComponent', () => {
})); }));
beforeEach(inject([Store], (store: Store<AppState>) => { describe('', () => {
store let testComp: TestComponent;
.subscribe((state) => { let testFixture: ComponentFixture<TestComponent>;
(state as any).core = Object.create({});
(state as any).core.auth = authState; // synchronous beforeEach
beforeEach(() => {
const html = `<ds-log-in [isStandalonePage]="isStandalonePage"> </ds-log-in>`;
testFixture = createTestComponent(html, TestComponent) as ComponentFixture<TestComponent>;
testComp = testFixture.componentInstance;
}); });
// create component and test fixture afterEach(() => {
fixture = TestBed.createComponent(LogInComponent); testFixture.destroy();
// get test component from the fixture
component = fixture.componentInstance;
// create page
page = new Page(component, fixture);
// verify the fixture is stable (no pending tasks)
fixture.whenStable().then(() => {
page.addPageElements();
}); });
it('should create LogInComponent', inject([LogInComponent], (app: LogInComponent) => {
expect(app).toBeDefined();
})); }));
it('should create a FormGroup comprised of FormControls', () => {
fixture.detectChanges();
expect(component.form instanceof FormGroup).toBe(true);
}); });
it('should authenticate', () => { describe('', () => {
beforeEach(() => {
fixture = TestBed.createComponent(LogInComponent);
component = fixture.componentInstance;
fixture.detectChanges(); fixture.detectChanges();
});
// set FormControl values afterEach(() => {
component.form.controls.email.setValue('user'); fixture.destroy();
component.form.controls.password.setValue('password'); component = null;
});
// submit form it('should render a log-in container component for each auth method available', () => {
component.submit(); const loginContainers = fixture.debugElement.queryAll(By.css('ds-log-in-container'));
expect(loginContainers.length).toBe(2);
// verify Store.dispatch() is invoked });
expect(page.navigateSpy.calls.any()).toBe(true, 'Store.dispatch not invoked');
}); });
}); });
/** // declare a test component
* I represent the DOM elements and attach spies. @Component({
* selector: 'ds-test-cmp',
* @class Page template: ``
*/ })
class Page { class TestComponent {
public emailInput: HTMLInputElement; isStandalonePage = true;
public navigateSpy: jasmine.Spy;
public passwordInput: HTMLInputElement;
constructor(private component: LogInComponent, private fixture: ComponentFixture<LogInComponent>) {
// use injector to get services
const injector = fixture.debugElement.injector;
const store = injector.get(Store);
// add spies
this.navigateSpy = spyOn(store, 'dispatch');
}
public addPageElements() {
const emailInputSelector = 'input[formcontrolname=\'email\']';
this.emailInput = this.fixture.debugElement.query(By.css(emailInputSelector)).nativeElement;
const passwordInputSelector = 'input[formcontrolname=\'password\']';
this.passwordInput = this.fixture.debugElement.query(By.css(passwordInputSelector)).nativeElement;
}
} }

View File

@@ -1,26 +1,13 @@
import { filter, map, takeWhile } from 'rxjs/operators';
import { Component, Input, OnDestroy, OnInit } from '@angular/core'; import { Component, Input, OnDestroy, OnInit } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { select, Store } from '@ngrx/store';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { import { filter, takeWhile, } from 'rxjs/operators';
AuthenticateAction, import { select, Store } from '@ngrx/store';
ResetAuthenticationMessagesAction
} from '../../core/auth/auth.actions';
import { import { AuthMethod } from '../../core/auth/models/auth.method';
getAuthenticationError, import { getAuthenticationMethods, isAuthenticated, isAuthenticationLoading } from '../../core/auth/selectors';
getAuthenticationInfo,
isAuthenticated,
isAuthenticationLoading,
} from '../../core/auth/selectors';
import { CoreState } from '../../core/core.reducers'; import { CoreState } from '../../core/core.reducers';
import { isNotEmpty } from '../empty.util';
import { fadeOut } from '../animations/fade';
import { AuthService } from '../../core/auth/auth.service'; import { AuthService } from '../../core/auth/auth.service';
import { Router } from '@angular/router';
/** /**
* /users/sign-in * /users/sign-in
@@ -29,34 +16,21 @@ import { Router } from '@angular/router';
@Component({ @Component({
selector: 'ds-log-in', selector: 'ds-log-in',
templateUrl: './log-in.component.html', templateUrl: './log-in.component.html',
styleUrls: ['./log-in.component.scss'], styleUrls: ['./log-in.component.scss']
animations: [fadeOut]
}) })
export class LogInComponent implements OnDestroy, OnInit { export class LogInComponent implements OnInit, OnDestroy {
/** /**
* The error if authentication fails. * A boolean representing if LogInComponent is in a standalone page
* @type {Observable<string>}
*/
public error: Observable<string>;
/**
* Has authentication error.
* @type {boolean} * @type {boolean}
*/ */
public hasError = false; @Input() isStandalonePage: boolean;
/** /**
* The authentication info message. * The list of authentication methods available
* @type {Observable<string>} * @type {AuthMethod[]}
*/ */
public message: Observable<string>; public authMethods: Observable<AuthMethod[]>;
/**
* Has authentication message.
* @type {boolean}
*/
public hasMessage = false;
/** /**
* Whether user is authenticated. * Whether user is authenticated.
@@ -70,69 +44,28 @@ export class LogInComponent implements OnDestroy, OnInit {
*/ */
public loading: Observable<boolean>; public loading: Observable<boolean>;
/**
* The authentication form.
* @type {FormGroup}
*/
public form: FormGroup;
/** /**
* Component state. * Component state.
* @type {boolean} * @type {boolean}
*/ */
private alive = true; private alive = true;
@Input() isStandalonePage: boolean; constructor(private store: Store<CoreState>,
private authService: AuthService,) {
/**
* @constructor
* @param {AuthService} authService
* @param {FormBuilder} formBuilder
* @param {Router} router
* @param {Store<State>} store
*/
constructor(
private authService: AuthService,
private formBuilder: FormBuilder,
private store: Store<CoreState>
) {
} }
/** ngOnInit(): void {
* Lifecycle hook that is called after data-bound properties of a directive are initialized.
* @method ngOnInit
*/
public ngOnInit() {
// set isAuthenticated
this.isAuthenticated = this.store.pipe(select(isAuthenticated));
// set formGroup this.authMethods = this.store.pipe(
this.form = this.formBuilder.group({ select(getAuthenticationMethods),
email: ['', Validators.required],
password: ['', Validators.required]
});
// set error
this.error = this.store.pipe(select(
getAuthenticationError),
map((error) => {
this.hasError = (isNotEmpty(error));
return error;
})
);
// set error
this.message = this.store.pipe(
select(getAuthenticationInfo),
map((message) => {
this.hasMessage = (isNotEmpty(message));
return message;
})
); );
// set loading // set loading
this.loading = this.store.pipe(select(isAuthenticationLoading)); this.loading = this.store.pipe(select(isAuthenticationLoading));
// set isAuthenticated
this.isAuthenticated = this.store.pipe(select(isAuthenticated));
// subscribe to success // subscribe to success
this.store.pipe( this.store.pipe(
select(isAuthenticated), select(isAuthenticated),
@@ -142,55 +75,11 @@ export class LogInComponent implements OnDestroy, OnInit {
this.authService.redirectAfterLoginSuccess(this.isStandalonePage); this.authService.redirectAfterLoginSuccess(this.isStandalonePage);
} }
); );
} }
/** ngOnDestroy(): void {
* Lifecycle hook that is called when a directive, pipe or service is destroyed.
* @method ngOnDestroy
*/
public ngOnDestroy() {
this.alive = false; this.alive = false;
} }
/**
* Reset error or message.
*/
public resetErrorOrMessage() {
if (this.hasError || this.hasMessage) {
this.store.dispatch(new ResetAuthenticationMessagesAction());
this.hasError = false;
this.hasMessage = false;
}
}
/**
* To the registration page.
* @method register
*/
public register() {
// TODO enable after registration process is done
// this.router.navigate(['/register']);
}
/**
* Submit the authentication form.
* @method submit
*/
public submit() {
this.resetErrorOrMessage();
// get email and password values
const email: string = this.form.get('email').value;
const password: string = this.form.get('password').value;
// trim values
email.trim();
password.trim();
// dispatch AuthenticationAction
this.store.dispatch(new AuthenticateAction(email, password));
// clear form
this.form.reset();
}
} }

View File

@@ -0,0 +1,16 @@
import { AuthMethodType } from '../../../core/auth/models/auth.method-type';
const authMethodsMap = new Map();
export function renderAuthMethodFor(authMethodType: AuthMethodType) {
return function decorator(objectElement: any) {
if (!objectElement) {
return;
}
authMethodsMap.set(authMethodType, objectElement);
};
}
export function rendersAuthMethodType(authMethodType: AuthMethodType) {
return authMethodsMap.get(authMethodType);
}

View File

@@ -0,0 +1,27 @@
<form (ngSubmit)="submit()"
[formGroup]="form" novalidate>
<label for="inputEmail" class="sr-only">{{"login.form.email" | translate}}</label>
<input id="inputEmail"
autocomplete="off"
autofocus
class="form-control form-control-lg position-relative"
formControlName="email"
placeholder="{{'login.form.email' | translate}}"
required
type="email">
<label for="inputPassword" class="sr-only">{{"login.form.password" | translate}}</label>
<input id="inputPassword"
autocomplete="off"
class="form-control form-control-lg position-relative mb-3"
placeholder="{{'login.form.password' | translate}}"
formControlName="password"
required
type="password">
<div *ngIf="(error | async) && hasError" class="alert alert-danger" role="alert"
@fadeOut>{{ (error | async) | translate }}</div>
<div *ngIf="(message | async) && hasMessage" class="alert alert-info" role="alert"
@fadeOut>{{ (message | async) | translate }}</div>
<button class="btn btn-lg btn-primary btn-block mt-3" type="submit"
[disabled]="!form.valid">{{"login.form.submit" | translate}}</button>
</form>

View File

@@ -0,0 +1,13 @@
.form-login .form-control:focus {
z-index: 2;
}
.form-login input[type="email"] {
margin-bottom: -1px;
border-bottom-right-radius: 0;
border-bottom-left-radius: 0;
}
.form-login input[type="password"] {
border-top-left-radius: 0;
border-top-right-radius: 0;
}

View File

@@ -0,0 +1,131 @@
import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import { async, ComponentFixture, inject, TestBed } from '@angular/core/testing';
import { FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms';
import { By } from '@angular/platform-browser';
import { Store, StoreModule } from '@ngrx/store';
import { TranslateModule } from '@ngx-translate/core';
import { LogInPasswordComponent } from './log-in-password.component';
import { EPerson } from '../../../../core/eperson/models/eperson.model';
import { EPersonMock } from '../../../testing/eperson-mock';
import { authReducer } from '../../../../core/auth/auth.reducer';
import { AuthService } from '../../../../core/auth/auth.service';
import { AuthServiceStub } from '../../../testing/auth-service-stub';
import { AppState } from '../../../../app.reducer';
import { AuthMethod } from '../../../../core/auth/models/auth.method';
import { AuthMethodType } from '../../../../core/auth/models/auth.method-type';
describe('LogInPasswordComponent', () => {
let component: LogInPasswordComponent;
let fixture: ComponentFixture<LogInPasswordComponent>;
let page: Page;
let user: EPerson;
const authState = {
authenticated: false,
loaded: false,
loading: false,
};
beforeEach(() => {
user = EPersonMock;
});
beforeEach(async(() => {
// refine the test module by declaring the test component
TestBed.configureTestingModule({
imports: [
FormsModule,
ReactiveFormsModule,
StoreModule.forRoot(authReducer),
TranslateModule.forRoot()
],
declarations: [
LogInPasswordComponent
],
providers: [
{ provide: AuthService, useClass: AuthServiceStub },
{ provide: 'authMethodProvider', useValue: new AuthMethod(AuthMethodType.Password) }
],
schemas: [
CUSTOM_ELEMENTS_SCHEMA
]
})
.compileComponents();
}));
beforeEach(inject([Store], (store: Store<AppState>) => {
store
.subscribe((state) => {
(state as any).core = Object.create({});
(state as any).core.auth = authState;
});
// create component and test fixture
fixture = TestBed.createComponent(LogInPasswordComponent);
// get test component from the fixture
component = fixture.componentInstance;
// create page
page = new Page(component, fixture);
// verify the fixture is stable (no pending tasks)
fixture.whenStable().then(() => {
page.addPageElements();
});
}));
it('should create a FormGroup comprised of FormControls', () => {
fixture.detectChanges();
expect(component.form instanceof FormGroup).toBe(true);
});
it('should authenticate', () => {
fixture.detectChanges();
// set FormControl values
component.form.controls.email.setValue('user');
component.form.controls.password.setValue('password');
// submit form
component.submit();
// verify Store.dispatch() is invoked
expect(page.navigateSpy.calls.any()).toBe(true, 'Store.dispatch not invoked');
});
});
/**
* I represent the DOM elements and attach spies.
*
* @class Page
*/
class Page {
public emailInput: HTMLInputElement;
public navigateSpy: jasmine.Spy;
public passwordInput: HTMLInputElement;
constructor(private component: LogInPasswordComponent, private fixture: ComponentFixture<LogInPasswordComponent>) {
// use injector to get services
const injector = fixture.debugElement.injector;
const store = injector.get(Store);
// add spies
this.navigateSpy = spyOn(store, 'dispatch');
}
public addPageElements() {
const emailInputSelector = 'input[formcontrolname=\'email\']';
this.emailInput = this.fixture.debugElement.query(By.css(emailInputSelector)).nativeElement;
const passwordInputSelector = 'input[formcontrolname=\'password\']';
this.passwordInput = this.fixture.debugElement.query(By.css(passwordInputSelector)).nativeElement;
}
}

View File

@@ -0,0 +1,144 @@
import { map } from 'rxjs/operators';
import { Component, Inject, OnInit } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { select, Store } from '@ngrx/store';
import { Observable } from 'rxjs';
import { AuthenticateAction, ResetAuthenticationMessagesAction } from '../../../../core/auth/auth.actions';
import { getAuthenticationError, getAuthenticationInfo, } from '../../../../core/auth/selectors';
import { CoreState } from '../../../../core/core.reducers';
import { isNotEmpty } from '../../../empty.util';
import { fadeOut } from '../../../animations/fade';
import { AuthMethodType } from '../../../../core/auth/models/auth.method-type';
import { renderAuthMethodFor } from '../log-in.methods-decorator';
import { AuthMethod } from '../../../../core/auth/models/auth.method';
/**
* /users/sign-in
* @class LogInPasswordComponent
*/
@Component({
selector: 'ds-log-in-password',
templateUrl: './log-in-password.component.html',
styleUrls: ['./log-in-password.component.scss'],
animations: [fadeOut]
})
@renderAuthMethodFor(AuthMethodType.Password)
export class LogInPasswordComponent implements OnInit {
/**
* The authentication method data.
* @type {AuthMethod}
*/
public authMethod: AuthMethod;
/**
* The error if authentication fails.
* @type {Observable<string>}
*/
public error: Observable<string>;
/**
* Has authentication error.
* @type {boolean}
*/
public hasError = false;
/**
* The authentication info message.
* @type {Observable<string>}
*/
public message: Observable<string>;
/**
* Has authentication message.
* @type {boolean}
*/
public hasMessage = false;
/**
* The authentication form.
* @type {FormGroup}
*/
public form: FormGroup;
/**
* @constructor
* @param {AuthMethod} injectedAuthMethodModel
* @param {FormBuilder} formBuilder
* @param {Store<State>} store
*/
constructor(
@Inject('authMethodProvider') public injectedAuthMethodModel: AuthMethod,
private formBuilder: FormBuilder,
private store: Store<CoreState>
) {
this.authMethod = injectedAuthMethodModel;
}
/**
* Lifecycle hook that is called after data-bound properties of a directive are initialized.
* @method ngOnInit
*/
public ngOnInit() {
// set formGroup
this.form = this.formBuilder.group({
email: ['', Validators.required],
password: ['', Validators.required]
});
// set error
this.error = this.store.pipe(select(
getAuthenticationError),
map((error) => {
this.hasError = (isNotEmpty(error));
return error;
})
);
// set error
this.message = this.store.pipe(
select(getAuthenticationInfo),
map((message) => {
this.hasMessage = (isNotEmpty(message));
return message;
})
);
}
/**
* Reset error or message.
*/
public resetErrorOrMessage() {
if (this.hasError || this.hasMessage) {
this.store.dispatch(new ResetAuthenticationMessagesAction());
this.hasError = false;
this.hasMessage = false;
}
}
/**
* Submit the authentication form.
* @method submit
*/
public submit() {
this.resetErrorOrMessage();
// get email and password values
const email: string = this.form.get('email').value;
const password: string = this.form.get('password').value;
// trim values
email.trim();
password.trim();
// dispatch AuthenticationAction
this.store.dispatch(new AuthenticateAction(email, password));
// clear form
this.form.reset();
}
}

View File

@@ -0,0 +1,7 @@
<button class="btn btn-lg btn-primary btn-block mt-2 text-white" (click)="redirectToShibboleth()" role="button">
{{"login.form.shibboleth" | translate}}
</button>

View File

@@ -0,0 +1,139 @@
import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { async, ComponentFixture, inject, TestBed } from '@angular/core/testing';
import { Store, StoreModule } from '@ngrx/store';
import { TranslateModule } from '@ngx-translate/core';
import { EPerson } from '../../../../core/eperson/models/eperson.model';
import { EPersonMock } from '../../../testing/eperson-mock';
import { authReducer } from '../../../../core/auth/auth.reducer';
import { AuthService } from '../../../../core/auth/auth.service';
import { AuthServiceStub } from '../../../testing/auth-service-stub';
import { AppState } from '../../../../app.reducer';
import { AuthMethod } from '../../../../core/auth/models/auth.method';
import { AuthMethodType } from '../../../../core/auth/models/auth.method-type';
import { LogInShibbolethComponent } from './log-in-shibboleth.component';
import { NativeWindowService } from '../../../../core/services/window.service';
import { RouterStub } from '../../../testing/router-stub';
import { ActivatedRouteStub } from '../../../testing/active-router-stub';
import { NativeWindowMockFactory } from '../../../mocks/mock-native-window-ref';
describe('LogInShibbolethComponent', () => {
let component: LogInShibbolethComponent;
let fixture: ComponentFixture<LogInShibbolethComponent>;
let page: Page;
let user: EPerson;
let componentAsAny: any;
let setHrefSpy;
const shibbolethBaseUrl = 'dspace-rest.test/shibboleth?redirectUrl=';
const location = shibbolethBaseUrl + 'http://dspace-angular.test/home';
const authState = {
authenticated: false,
loaded: false,
loading: false,
};
beforeEach(() => {
user = EPersonMock;
});
beforeEach(async(() => {
// refine the test module by declaring the test component
TestBed.configureTestingModule({
imports: [
StoreModule.forRoot(authReducer),
TranslateModule.forRoot()
],
declarations: [
LogInShibbolethComponent
],
providers: [
{ provide: AuthService, useClass: AuthServiceStub },
{ provide: 'authMethodProvider', useValue: new AuthMethod(AuthMethodType.Shibboleth, location) },
{ provide: NativeWindowService, useFactory: NativeWindowMockFactory },
{ provide: Router, useValue: new RouterStub() },
{ provide: ActivatedRoute, useValue: new ActivatedRouteStub() },
],
schemas: [
CUSTOM_ELEMENTS_SCHEMA
]
})
.compileComponents();
}));
beforeEach(inject([Store], (store: Store<AppState>) => {
store
.subscribe((state) => {
(state as any).core = Object.create({});
(state as any).core.auth = authState;
});
// create component and test fixture
fixture = TestBed.createComponent(LogInShibbolethComponent);
// get test component from the fixture
component = fixture.componentInstance;
componentAsAny = component;
// create page
page = new Page(component, fixture);
setHrefSpy = spyOnProperty(componentAsAny._window.nativeWindow.location, 'href', 'set').and.callThrough();
}));
it('should set the properly a new redirectUrl', () => {
const currentUrl = 'http://dspace-angular.test/collections/12345';
componentAsAny._window.nativeWindow.location.href = currentUrl;
fixture.detectChanges();
expect(componentAsAny.injectedAuthMethodModel.location).toBe(location);
expect(componentAsAny._window.nativeWindow.location.href).toBe(currentUrl);
component.redirectToShibboleth();
expect(setHrefSpy).toHaveBeenCalledWith(shibbolethBaseUrl + currentUrl)
});
it('should not set a new redirectUrl', () => {
const currentUrl = 'http://dspace-angular.test/home';
componentAsAny._window.nativeWindow.location.href = currentUrl;
fixture.detectChanges();
expect(componentAsAny.injectedAuthMethodModel.location).toBe(location);
expect(componentAsAny._window.nativeWindow.location.href).toBe(currentUrl);
component.redirectToShibboleth();
expect(setHrefSpy).toHaveBeenCalledWith(shibbolethBaseUrl + currentUrl)
});
});
/**
* I represent the DOM elements and attach spies.
*
* @class Page
*/
class Page {
public emailInput: HTMLInputElement;
public navigateSpy: jasmine.Spy;
public passwordInput: HTMLInputElement;
constructor(private component: LogInShibbolethComponent, private fixture: ComponentFixture<LogInShibbolethComponent>) {
// use injector to get services
const injector = fixture.debugElement.injector;
const store = injector.get(Store);
// add spies
this.navigateSpy = spyOn(store, 'dispatch');
}
}

View File

@@ -0,0 +1,95 @@
import { Component, Inject, OnInit, } from '@angular/core';
import { Observable } from 'rxjs';
import { select, Store } from '@ngrx/store';
import { renderAuthMethodFor } from '../log-in.methods-decorator';
import { AuthMethodType } from '../../../../core/auth/models/auth.method-type';
import { AuthMethod } from '../../../../core/auth/models/auth.method';
import { CoreState } from '../../../../core/core.reducers';
import { isAuthenticated, isAuthenticationLoading } from '../../../../core/auth/selectors';
import { RouteService } from '../../../../core/services/route.service';
import { NativeWindowRef, NativeWindowService } from '../../../../core/services/window.service';
import { isNotNull } from '../../../empty.util';
@Component({
selector: 'ds-log-in-shibboleth',
templateUrl: './log-in-shibboleth.component.html',
styleUrls: ['./log-in-shibboleth.component.scss'],
})
@renderAuthMethodFor(AuthMethodType.Shibboleth)
export class LogInShibbolethComponent implements OnInit {
/**
* The authentication method data.
* @type {AuthMethod}
*/
public authMethod: AuthMethod;
/**
* True if the authentication is loading.
* @type {boolean}
*/
public loading: Observable<boolean>;
/**
* The shibboleth authentication location url.
* @type {string}
*/
public location: string;
/**
* Whether user is authenticated.
* @type {Observable<string>}
*/
public isAuthenticated: Observable<boolean>;
/**
* @constructor
* @param {AuthMethod} injectedAuthMethodModel
* @param {NativeWindowRef} _window
* @param {RouteService} route
* @param {Store<State>} store
*/
constructor(
@Inject('authMethodProvider') public injectedAuthMethodModel: AuthMethod,
@Inject(NativeWindowService) protected _window: NativeWindowRef,
private route: RouteService,
private store: Store<CoreState>
) {
this.authMethod = injectedAuthMethodModel;
}
ngOnInit(): void {
// set isAuthenticated
this.isAuthenticated = this.store.pipe(select(isAuthenticated));
// set loading
this.loading = this.store.pipe(select(isAuthenticationLoading));
// set location
this.location = decodeURIComponent(this.injectedAuthMethodModel.location);
}
redirectToShibboleth() {
let newLocationUrl = this.location;
const currentUrl = this._window.nativeWindow.location.href;
const myRegexp = /\?redirectUrl=(.*)/g;
const match = myRegexp.exec(this.location);
const redirectUrl = (match && match[1]) ? match[1] : null;
// Check whether the current page is different from the redirect url received from rest
if (isNotNull(redirectUrl) && redirectUrl !== currentUrl) {
// change the redirect url with the current page url
const newRedirectUrl = `?redirectUrl=${currentUrl}`;
newLocationUrl = this.location.replace(/\?redirectUrl=(.*)/g, newRedirectUrl);
}
// redirect to shibboleth authentication url
this._window.nativeWindow.location.href = newLocationUrl;
}
}

View File

@@ -1 +1 @@
@import '../log-in/log-in.component.scss'; @import '../log-in/methods/password/log-in-password.component';

View File

@@ -0,0 +1,21 @@
export const MockWindow = {
location: {
_href: '',
set href(url: string) {
this._href = url;
},
get href() {
return this._href;
}
}
};
export class NativeWindowRefMock {
get nativeWindow(): any {
return MockWindow;
}
}
export function NativeWindowMockFactory() {
return new NativeWindowRefMock();
}

View File

@@ -99,6 +99,13 @@ export class PaginationComponent implements OnDestroy, OnInit {
*/ */
@Input() public hidePagerWhenSinglePage = true; @Input() public hidePagerWhenSinglePage = true;
/**
* Option for disabling updating and reading route parameters on pagination changes
* In other words, changing pagination won't add or update the url parameters on the current page, and the url
* parameters won't affect the pagination of this component
*/
@Input() public disableRouteParameterUpdate = false;
/** /**
* Current page. * Current page.
*/ */
@@ -173,20 +180,35 @@ export class PaginationComponent implements OnDestroy, OnInit {
this.checkConfig(this.paginationOptions); this.checkConfig(this.paginationOptions);
this.initializeConfig(); this.initializeConfig();
// Listen to changes // Listen to changes
if (!this.disableRouteParameterUpdate) {
this.subs.push(this.route.queryParams this.subs.push(this.route.queryParams
.subscribe((queryParams) => { .subscribe((queryParams) => {
if (this.isEmptyPaginationParams(queryParams)) { this.initializeParams(queryParams);
this.initializeConfig(queryParams); }));
}
}
/**
* Initialize the route and current parameters
* This method will fix any invalid or missing parameters
* @param params
*/
private initializeParams(params) {
if (this.isEmptyPaginationParams(params)) {
this.initializeConfig(params);
} else { } else {
this.currentQueryParams = queryParams; this.currentQueryParams = params;
const fixedProperties = this.validateParams(queryParams); const fixedProperties = this.validateParams(params);
if (isNotEmpty(fixedProperties)) { if (isNotEmpty(fixedProperties)) {
if (!this.disableRouteParameterUpdate) {
this.fixRoute(fixedProperties); this.fixRoute(fixedProperties);
} else {
this.initializeParams(fixedProperties);
}
} else { } else {
this.setFields(); this.setFields();
} }
} }
}));
} }
private fixRoute(fixedProperties) { private fixRoute(fixedProperties) {
@@ -247,7 +269,7 @@ export class PaginationComponent implements OnDestroy, OnInit {
* The page being navigated to. * The page being navigated to.
*/ */
public doPageChange(page: number) { public doPageChange(page: number) {
this.updateRoute({ pageId: this.id, page: page.toString() }); this.updateParams(Object.assign({}, this.currentQueryParams, { pageId: this.id, page: page.toString() }));
} }
/** /**
@@ -257,7 +279,7 @@ export class PaginationComponent implements OnDestroy, OnInit {
* The page size being navigated to. * The page size being navigated to.
*/ */
public doPageSizeChange(pageSize: number) { public doPageSizeChange(pageSize: number) {
this.updateRoute({ pageId: this.id, page: 1, pageSize: pageSize }); this.updateParams(Object.assign({}, this.currentQueryParams,{ pageId: this.id, page: 1, pageSize: pageSize }));
} }
/** /**
@@ -267,7 +289,7 @@ export class PaginationComponent implements OnDestroy, OnInit {
* The sort direction being navigated to. * The sort direction being navigated to.
*/ */
public doSortDirectionChange(sortDirection: SortDirection) { public doSortDirectionChange(sortDirection: SortDirection) {
this.updateRoute({ pageId: this.id, page: 1, sortDirection: sortDirection }); this.updateParams(Object.assign({}, this.currentQueryParams,{ pageId: this.id, page: 1, sortDirection: sortDirection }));
} }
/** /**
@@ -277,7 +299,7 @@ export class PaginationComponent implements OnDestroy, OnInit {
* The sort field being navigated to. * The sort field being navigated to.
*/ */
public doSortFieldChange(field: string) { public doSortFieldChange(field: string) {
this.updateRoute({ pageId: this.id, page: 1, sortField: field }); this.updateParams(Object.assign(this.currentQueryParams,{ pageId: this.id, page: 1, sortField: field }));
} }
/** /**
@@ -347,6 +369,20 @@ export class PaginationComponent implements OnDestroy, OnInit {
}) })
} }
/**
* Update the current query params and optionally update the route
* @param params
*/
private updateParams(params: {}) {
if (isNotEmpty(difference(params, this.currentQueryParams))) {
if (!this.disableRouteParameterUpdate) {
this.updateRoute(params);
} else {
this.initializeParams(params);
}
}
}
/** /**
* Method to update the route parameters * Method to update the route parameters
*/ */

View File

@@ -42,13 +42,15 @@ import { SearchResultGridElementComponent } from './object-grid/search-result-gr
import { ViewModeSwitchComponent } from './view-mode-switch/view-mode-switch.component'; import { ViewModeSwitchComponent } from './view-mode-switch/view-mode-switch.component';
import { GridThumbnailComponent } from './object-grid/grid-thumbnail/grid-thumbnail.component'; import { GridThumbnailComponent } from './object-grid/grid-thumbnail/grid-thumbnail.component';
import { VarDirective } from './utils/var.directive'; import { VarDirective } from './utils/var.directive';
import { LogInComponent } from './log-in/log-in.component';
import { AuthNavMenuComponent } from './auth-nav-menu/auth-nav-menu.component'; import { AuthNavMenuComponent } from './auth-nav-menu/auth-nav-menu.component';
import { LogOutComponent } from './log-out/log-out.component'; import { LogOutComponent } from './log-out/log-out.component';
import { FormComponent } from './form/form.component'; import { FormComponent } from './form/form.component';
import { DsDynamicTypeaheadComponent } from './form/builder/ds-dynamic-form-ui/models/typeahead/dynamic-typeahead.component'; import { DsDynamicTypeaheadComponent } from './form/builder/ds-dynamic-form-ui/models/typeahead/dynamic-typeahead.component';
import { DsDynamicScrollableDropdownComponent } from './form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component'; import { DsDynamicScrollableDropdownComponent } from './form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component';
import { DsDynamicFormControlContainerComponent, dsDynamicFormControlMapFn } from './form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component'; import {
DsDynamicFormControlContainerComponent,
dsDynamicFormControlMapFn
} from './form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component';
import { DsDynamicFormComponent } from './form/builder/ds-dynamic-form-ui/ds-dynamic-form.component'; import { DsDynamicFormComponent } from './form/builder/ds-dynamic-form-ui/ds-dynamic-form.component';
import { DYNAMIC_FORM_CONTROL_MAP_FN, DynamicFormsCoreModule } from '@ng-dynamic-forms/core'; import { DYNAMIC_FORM_CONTROL_MAP_FN, DynamicFormsCoreModule } from '@ng-dynamic-forms/core';
import { DynamicFormsNGBootstrapUIModule } from '@ng-dynamic-forms/ui-ng-bootstrap'; import { DynamicFormsNGBootstrapUIModule } from '@ng-dynamic-forms/ui-ng-bootstrap';
@@ -176,8 +178,14 @@ import { ExternalSourceEntryImportModalComponent } from './form/builder/ds-dynam
import { ImportableListItemControlComponent } from './object-collection/shared/importable-list-item-control/importable-list-item-control.component'; import { ImportableListItemControlComponent } from './object-collection/shared/importable-list-item-control/importable-list-item-control.component';
import { DragDropModule } from '@angular/cdk/drag-drop'; import { DragDropModule } from '@angular/cdk/drag-drop';
import { ExistingMetadataListElementComponent } from './form/builder/ds-dynamic-form-ui/existing-metadata-list-element/existing-metadata-list-element.component'; import { ExistingMetadataListElementComponent } from './form/builder/ds-dynamic-form-ui/existing-metadata-list-element/existing-metadata-list-element.component';
import { ItemVersionsComponent } from './item/item-versions/item-versions.component';
import { SortablejsModule } from 'ngx-sortablejs'; import { SortablejsModule } from 'ngx-sortablejs';
import { LogInContainerComponent } from './log-in/container/log-in-container.component';
import { LogInShibbolethComponent } from './log-in/methods/shibboleth/log-in-shibboleth.component';
import { LogInPasswordComponent } from './log-in/methods/password/log-in-password.component';
import { LogInComponent } from './log-in/log-in.component';
import { MissingTranslationHelper } from './translate/missing-translation.helper'; import { MissingTranslationHelper } from './translate/missing-translation.helper';
import { ItemVersionsNoticeComponent } from './item/item-versions/notice/item-versions-notice.component';
const MODULES = [ const MODULES = [
// Do NOT include UniversalModule, HttpModule, or JsonpModule here // Do NOT include UniversalModule, HttpModule, or JsonpModule here
@@ -344,7 +352,12 @@ const COMPONENTS = [
ExternalSourceEntryImportModalComponent, ExternalSourceEntryImportModalComponent,
ImportableListItemControlComponent, ImportableListItemControlComponent,
ExistingMetadataListElementComponent, ExistingMetadataListElementComponent,
LogInShibbolethComponent,
LogInPasswordComponent,
LogInContainerComponent,
ItemVersionsComponent,
PublicationSearchResultListElementComponent, PublicationSearchResultListElementComponent,
ItemVersionsNoticeComponent
]; ];
const ENTRY_COMPONENTS = [ const ENTRY_COMPONENTS = [
@@ -408,6 +421,10 @@ const ENTRY_COMPONENTS = [
DsDynamicLookupRelationSelectionTabComponent, DsDynamicLookupRelationSelectionTabComponent,
DsDynamicLookupRelationExternalSourceTabComponent, DsDynamicLookupRelationExternalSourceTabComponent,
ExternalSourceEntryImportModalComponent, ExternalSourceEntryImportModalComponent,
LogInPasswordComponent,
LogInShibbolethComponent,
ItemVersionsComponent,
ItemVersionsNoticeComponent
]; ];
const SHARED_ITEM_PAGE_COMPONENTS = [ const SHARED_ITEM_PAGE_COMPONENTS = [

View File

@@ -23,7 +23,7 @@ export class AuthRequestServiceStub {
} else { } else {
authStatusStub.authenticated = false; authStatusStub.authenticated = false;
} }
} else { } else if (isNotEmpty(options)) {
const token = (options.headers as any).lazyUpdate[1].value; const token = (options.headers as any).lazyUpdate[1].value;
if (this.validateToken(token)) { if (this.validateToken(token)) {
authStatusStub.authenticated = true; authStatusStub.authenticated = true;
@@ -32,6 +32,8 @@ export class AuthRequestServiceStub {
} else { } else {
authStatusStub.authenticated = false; authStatusStub.authenticated = false;
} }
} else {
authStatusStub.authenticated = false;
} }
return observableOf(authStatusStub); return observableOf(authStatusStub);
} }
@@ -43,7 +45,7 @@ export class AuthRequestServiceStub {
authStatusStub.authenticated = false; authStatusStub.authenticated = false;
break; break;
case 'status': case 'status':
const token = (options.headers as any).lazyUpdate[1].value; const token = ((options.headers as any).lazyUpdate[1]) ? (options.headers as any).lazyUpdate[1].value : null;
if (this.validateToken(token)) { if (this.validateToken(token)) {
authStatusStub.authenticated = true; authStatusStub.authenticated = true;
authStatusStub.token = this.mockTokenInfo; authStatusStub.token = this.mockTokenInfo;

View File

@@ -4,6 +4,12 @@ import { AuthTokenInfo } from '../../core/auth/models/auth-token-info.model';
import { EPersonMock } from './eperson.mock'; import { EPersonMock } from './eperson.mock';
import { EPerson } from '../../core/eperson/models/eperson.model'; import { EPerson } from '../../core/eperson/models/eperson.model';
import { createSuccessfulRemoteDataObject$ } from '../remote-data.utils'; import { createSuccessfulRemoteDataObject$ } from '../remote-data.utils';
import { AuthMethod } from '../../core/auth/models/auth.method';
export const authMethodsMock = [
new AuthMethod('password'),
new AuthMethod('shibboleth', 'dspace.test/shibboleth')
];
export class AuthServiceStub { export class AuthServiceStub {
@@ -106,4 +112,12 @@ export class AuthServiceStub {
isAuthenticated() { isAuthenticated() {
return observableOf(true); return observableOf(true);
} }
checkAuthenticationCookie() {
return;
}
retrieveAuthMethodsFromAuthStatus(status: AuthStatus) {
return observableOf(authMethodsMock);
}
} }

View File

@@ -1005,6 +1005,12 @@
"item.edit.tabs.status.title": "Item Edit - Status", "item.edit.tabs.status.title": "Item Edit - Status",
"item.edit.tabs.versionhistory.head": "Version History",
"item.edit.tabs.versionhistory.title": "Item Edit - Version History",
"item.edit.tabs.versionhistory.under-construction": "Editing or adding new versions is not yet possible in this user interface.",
"item.edit.tabs.view.head": "View Item", "item.edit.tabs.view.head": "View Item",
"item.edit.tabs.view.title": "Item Edit - View", "item.edit.tabs.view.title": "Item Edit - View",
@@ -1084,6 +1090,29 @@
"item.select.table.title": "Title", "item.select.table.title": "Title",
"item.version.history.empty": "There are no other versions for this item yet.",
"item.version.history.head": "Version History",
"item.version.history.return": "Return",
"item.version.history.selected": "Selected version",
"item.version.history.table.version": "Version",
"item.version.history.table.item": "Item",
"item.version.history.table.editor": "Editor",
"item.version.history.table.date": "Date",
"item.version.history.table.summary": "Summary",
"item.version.notice": "This is not the latest version of this item. The latest version can be found <a href='{{destination}}'>here</a>.",
"journal.listelement.badge": "Journal", "journal.listelement.badge": "Journal",
@@ -1175,8 +1204,12 @@
"login.form.new-user": "New user? Click here to register.", "login.form.new-user": "New user? Click here to register.",
"login.form.or-divider": "or",
"login.form.password": "Password", "login.form.password": "Password",
"login.form.shibboleth": "Log in with Shibboleth",
"login.form.submit": "Log in", "login.form.submit": "Log in",
"login.title": "Login", "login.title": "Login",

View File

@@ -918,7 +918,7 @@
"item.edit.move.processing": "Movendo...", "item.edit.move.processing": "Movendo...",
// "item.edit.move.search.placeholder": "Enter a search query to look for collections", // "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": "The item has been moved successfully",
"item.edit.move.success": "O item foi movido com sucesso", "item.edit.move.success": "O item foi movido com sucesso",

View File

@@ -0,0 +1,10 @@
import { Config } from './config.interface';
export interface AuthTarget {
host: string;
page: string;
}
export interface AuthConfig extends Config {
target: AuthTarget;
}

View File

@@ -10,12 +10,14 @@ import { BrowseByConfig } from './browse-by-config.interface';
import { ItemPageConfig } from './item-page-config.interface'; import { ItemPageConfig } from './item-page-config.interface';
import { CollectionPageConfig } from './collection-page-config.interface'; import { CollectionPageConfig } from './collection-page-config.interface';
import { Theme } from './theme.inferface'; import { Theme } from './theme.inferface';
import {AuthConfig} from './auth-config.interfaces';
export interface GlobalConfig extends Config { export interface GlobalConfig extends Config {
ui: ServerConfig; ui: ServerConfig;
rest: ServerConfig; rest: ServerConfig;
production: boolean; production: boolean;
cache: CacheConfig; cache: CacheConfig;
auth: AuthConfig;
form: FormConfig; form: FormConfig;
notifications: INotificationBoardOptions; notifications: INotificationBoardOptions;
submission: SubmissionConfig; submission: SubmissionConfig;