Merge pull request #822 from atmire/Features-support-part-2

Features support part 2
This commit is contained in:
Tim Donohue
2020-09-23 14:37:30 -05:00
committed by GitHub
26 changed files with 414 additions and 70 deletions

View File

@@ -0,0 +1,30 @@
import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router';
import { Collection } from '../core/shared/collection.model';
import { CollectionPageResolver } from './collection-page.resolver';
import { AuthorizationDataService } from '../core/data/feature-authorization/authorization-data.service';
import { of as observableOf } from 'rxjs';
import { DsoPageFeatureGuard } from '../core/data/feature-authorization/feature-authorization-guard/dso-page-feature.guard';
import { Observable } from 'rxjs/internal/Observable';
import { FeatureID } from '../core/data/feature-authorization/feature-id';
@Injectable({
providedIn: 'root'
})
/**
* Guard for preventing unauthorized access to certain {@link Collection} pages requiring administrator rights
*/
export class CollectionPageAdministratorGuard extends DsoPageFeatureGuard<Collection> {
constructor(protected resolver: CollectionPageResolver,
protected authorizationService: AuthorizationDataService,
protected router: Router) {
super(resolver, authorizationService, router);
}
/**
* Check administrator authorization rights
*/
getFeatureID(next: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<FeatureID> {
return observableOf(FeatureID.AdministratorOf);
}
}

View File

@@ -19,6 +19,7 @@ import {
COLLECTION_EDIT_PATH, COLLECTION_EDIT_PATH,
COLLECTION_CREATE_PATH COLLECTION_CREATE_PATH
} from './collection-page-routing-paths'; } from './collection-page-routing-paths';
import { CollectionPageAdministratorGuard } from './collection-page-administrator.guard';
@NgModule({ @NgModule({
imports: [ imports: [
@@ -39,7 +40,7 @@ import {
{ {
path: COLLECTION_EDIT_PATH, path: COLLECTION_EDIT_PATH,
loadChildren: './edit-collection-page/edit-collection-page.module#EditCollectionPageModule', loadChildren: './edit-collection-page/edit-collection-page.module#EditCollectionPageModule',
canActivate: [AuthenticatedGuard] canActivate: [CollectionPageAdministratorGuard]
}, },
{ {
path: 'delete', path: 'delete',
@@ -78,7 +79,8 @@ import {
CollectionBreadcrumbResolver, CollectionBreadcrumbResolver,
DSOBreadcrumbsService, DSOBreadcrumbsService,
LinkService, LinkService,
CreateCollectionPageGuard CreateCollectionPageGuard,
CollectionPageAdministratorGuard
] ]
}) })
export class CollectionPageRoutingModule { export class CollectionPageRoutingModule {

View File

@@ -0,0 +1,30 @@
import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router';
import { Community } from '../core/shared/community.model';
import { CommunityPageResolver } from './community-page.resolver';
import { AuthorizationDataService } from '../core/data/feature-authorization/authorization-data.service';
import { of as observableOf } from 'rxjs';
import { DsoPageFeatureGuard } from '../core/data/feature-authorization/feature-authorization-guard/dso-page-feature.guard';
import { Observable } from 'rxjs/internal/Observable';
import { FeatureID } from '../core/data/feature-authorization/feature-id';
@Injectable({
providedIn: 'root'
})
/**
* Guard for preventing unauthorized access to certain {@link Community} pages requiring administrator rights
*/
export class CommunityPageAdministratorGuard extends DsoPageFeatureGuard<Community> {
constructor(protected resolver: CommunityPageResolver,
protected authorizationService: AuthorizationDataService,
protected router: Router) {
super(resolver, authorizationService, router);
}
/**
* Check administrator authorization rights
*/
getFeatureID(next: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<FeatureID> {
return observableOf(FeatureID.AdministratorOf);
}
}

View File

@@ -11,6 +11,7 @@ import { CommunityBreadcrumbResolver } from '../core/breadcrumbs/community-bread
import { DSOBreadcrumbsService } from '../core/breadcrumbs/dso-breadcrumbs.service'; import { DSOBreadcrumbsService } from '../core/breadcrumbs/dso-breadcrumbs.service';
import { LinkService } from '../core/cache/builders/link.service'; import { LinkService } from '../core/cache/builders/link.service';
import { COMMUNITY_EDIT_PATH, COMMUNITY_CREATE_PATH } from './community-page-routing-paths'; import { COMMUNITY_EDIT_PATH, COMMUNITY_CREATE_PATH } from './community-page-routing-paths';
import { CommunityPageAdministratorGuard } from './community-page-administrator.guard';
@NgModule({ @NgModule({
imports: [ imports: [
@@ -31,7 +32,7 @@ import { COMMUNITY_EDIT_PATH, COMMUNITY_CREATE_PATH } from './community-page-rou
{ {
path: COMMUNITY_EDIT_PATH, path: COMMUNITY_EDIT_PATH,
loadChildren: './edit-community-page/edit-community-page.module#EditCommunityPageModule', loadChildren: './edit-community-page/edit-community-page.module#EditCommunityPageModule',
canActivate: [AuthenticatedGuard] canActivate: [CommunityPageAdministratorGuard]
}, },
{ {
path: 'delete', path: 'delete',
@@ -53,7 +54,8 @@ import { COMMUNITY_EDIT_PATH, COMMUNITY_CREATE_PATH } from './community-page-rou
CommunityBreadcrumbResolver, CommunityBreadcrumbResolver,
DSOBreadcrumbsService, DSOBreadcrumbsService,
LinkService, LinkService,
CreateCommunityPageGuard CreateCommunityPageGuard,
CommunityPageAdministratorGuard
] ]
}) })
export class CommunityPageRoutingModule { export class CommunityPageRoutingModule {

View File

@@ -29,6 +29,8 @@ import {
ITEM_EDIT_REINSTATE_PATH, ITEM_EDIT_REINSTATE_PATH,
ITEM_EDIT_WITHDRAW_PATH ITEM_EDIT_WITHDRAW_PATH
} from './edit-item-page.routing-paths'; } from './edit-item-page.routing-paths';
import { ItemPageReinstateGuard } from './item-page-reinstate.guard';
import { ItemPageWithdrawGuard } from './item-page-withdraw.guard';
/** /**
* Routing module that handles the routing for the Edit Item page administrator functionality * Routing module that handles the routing for the Edit Item page administrator functionality
@@ -98,10 +100,12 @@ import {
{ {
path: ITEM_EDIT_WITHDRAW_PATH, path: ITEM_EDIT_WITHDRAW_PATH,
component: ItemWithdrawComponent, component: ItemWithdrawComponent,
canActivate: [ItemPageWithdrawGuard]
}, },
{ {
path: ITEM_EDIT_REINSTATE_PATH, path: ITEM_EDIT_REINSTATE_PATH,
component: ItemReinstateComponent, component: ItemReinstateComponent,
canActivate: [ItemPageReinstateGuard]
}, },
{ {
path: ITEM_EDIT_PRIVATE_PATH, path: ITEM_EDIT_PRIVATE_PATH,
@@ -154,7 +158,9 @@ import {
I18nBreadcrumbResolver, I18nBreadcrumbResolver,
I18nBreadcrumbsService, I18nBreadcrumbsService,
ResourcePolicyResolver, ResourcePolicyResolver,
ResourcePolicyTargetResolver ResourcePolicyTargetResolver,
ItemPageReinstateGuard,
ItemPageWithdrawGuard
] ]
}) })
export class EditItemPageRoutingModule { export class EditItemPageRoutingModule {

View File

@@ -0,0 +1,30 @@
import { Injectable } from '@angular/core';
import { DsoPageFeatureGuard } from '../../core/data/feature-authorization/feature-authorization-guard/dso-page-feature.guard';
import { Item } from '../../core/shared/item.model';
import { ItemPageResolver } from '../item-page.resolver';
import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service';
import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router';
import { Observable } from 'rxjs/internal/Observable';
import { FeatureID } from '../../core/data/feature-authorization/feature-id';
import { of as observableOf } from 'rxjs';
@Injectable({
providedIn: 'root'
})
/**
* Guard for preventing unauthorized access to certain {@link Item} pages requiring reinstate rights
*/
export class ItemPageReinstateGuard extends DsoPageFeatureGuard<Item> {
constructor(protected resolver: ItemPageResolver,
protected authorizationService: AuthorizationDataService,
protected router: Router) {
super(resolver, authorizationService, router);
}
/**
* Check reinstate authorization rights
*/
getFeatureID(next: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<FeatureID> {
return observableOf(FeatureID.ReinstateItem);
}
}

View File

@@ -0,0 +1,30 @@
import { DsoPageFeatureGuard } from '../../core/data/feature-authorization/feature-authorization-guard/dso-page-feature.guard';
import { Item } from '../../core/shared/item.model';
import { Injectable } from '@angular/core';
import { ItemPageResolver } from '../item-page.resolver';
import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service';
import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router';
import { Observable } from 'rxjs/internal/Observable';
import { FeatureID } from '../../core/data/feature-authorization/feature-id';
import { of as observableOf } from 'rxjs';
@Injectable({
providedIn: 'root'
})
/**
* Guard for preventing unauthorized access to certain {@link Item} pages requiring withdraw rights
*/
export class ItemPageWithdrawGuard extends DsoPageFeatureGuard<Item> {
constructor(protected resolver: ItemPageResolver,
protected authorizationService: AuthorizationDataService,
protected router: Router) {
super(resolver, authorizationService, router);
}
/**
* Check withdraw authorization rights
*/
getFeatureID(next: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<FeatureID> {
return observableOf(FeatureID.WithdrawItem);
}
}

View File

@@ -15,7 +15,7 @@
<a [routerLink]="getItemPage((itemRD$ | async)?.payload)">{{getItemPage((itemRD$ | async)?.payload)}}</a> <a [routerLink]="getItemPage((itemRD$ | async)?.payload)">{{getItemPage((itemRD$ | async)?.payload)}}</a>
</div> </div>
<div *ngFor="let operation of operations" class="w-100 pt-3"> <div *ngFor="let operation of (operations$ | async)" class="w-100" [ngClass]="{'pt-3': operation}">
<ds-item-operation [operation]="operation"></ds-item-operation> <ds-item-operation *ngIf="operation" [operation]="operation"></ds-item-operation>
</div> </div>
</div> </div>

View File

@@ -12,6 +12,7 @@ import { By } from '@angular/platform-browser';
import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import { of as observableOf } from 'rxjs'; import { of as observableOf } from 'rxjs';
import { createSuccessfulRemoteDataObject } from '../../../shared/remote-data.utils'; import { createSuccessfulRemoteDataObject } from '../../../shared/remote-data.utils';
import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service';
describe('ItemStatusComponent', () => { describe('ItemStatusComponent', () => {
let comp: ItemStatusComponent; let comp: ItemStatusComponent;
@@ -20,7 +21,10 @@ describe('ItemStatusComponent', () => {
const mockItem = Object.assign(new Item(), { const mockItem = Object.assign(new Item(), {
id: 'fake-id', id: 'fake-id',
handle: 'fake/handle', handle: 'fake/handle',
lastModified: '2018' lastModified: '2018',
_links: {
self: { href: 'test-item-selflink' }
}
}); });
const itemPageUrl = `items/${mockItem.id}`; const itemPageUrl = `items/${mockItem.id}`;
@@ -31,13 +35,20 @@ describe('ItemStatusComponent', () => {
} }
}; };
let authorizationService: AuthorizationDataService;
beforeEach(async(() => { beforeEach(async(() => {
authorizationService = jasmine.createSpyObj('authorizationService', {
isAuthorized: observableOf(true)
});
TestBed.configureTestingModule({ TestBed.configureTestingModule({
imports: [CommonModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule], imports: [CommonModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule],
declarations: [ItemStatusComponent], declarations: [ItemStatusComponent],
providers: [ providers: [
{ provide: ActivatedRoute, useValue: routeStub }, { provide: ActivatedRoute, useValue: routeStub },
{ provide: HostWindowService, useValue: new HostWindowServiceStub(0) } { provide: HostWindowService, useValue: new HostWindowServiceStub(0) },
{ provide: AuthorizationDataService, useValue: authorizationService },
], schemas: [CUSTOM_ELEMENTS_SCHEMA] ], schemas: [CUSTOM_ELEMENTS_SCHEMA]
}).compileComponents(); }).compileComponents();
})); }));

View File

@@ -3,15 +3,19 @@ import { fadeIn, fadeInOut } from '../../../shared/animations/fade';
import { Item } from '../../../core/shared/item.model'; import { Item } from '../../../core/shared/item.model';
import { ActivatedRoute } from '@angular/router'; import { ActivatedRoute } from '@angular/router';
import { ItemOperation } from '../item-operation/itemOperation.model'; import { ItemOperation } from '../item-operation/itemOperation.model';
import { first, map } from 'rxjs/operators'; import { distinctUntilChanged, first, map } from 'rxjs/operators';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { RemoteData } from '../../../core/data/remote-data'; import { RemoteData } from '../../../core/data/remote-data';
import { getItemEditRoute, getItemPageRoute } from '../../item-page-routing-paths'; import { getItemEditRoute, getItemPageRoute } from '../../item-page-routing-paths';
import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service';
import { FeatureID } from '../../../core/data/feature-authorization/feature-id';
import { hasValue } from '../../../shared/empty.util';
import { BehaviorSubject } from 'rxjs/internal/BehaviorSubject';
@Component({ @Component({
selector: 'ds-item-status', selector: 'ds-item-status',
templateUrl: './item-status.component.html', templateUrl: './item-status.component.html',
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.Default,
animations: [ animations: [
fadeIn, fadeIn,
fadeInOut fadeInOut
@@ -40,14 +44,15 @@ export class ItemStatusComponent implements OnInit {
* The possible actions that can be performed on the item * The possible actions that can be performed on the item
* key: id value: url to action's component * key: id value: url to action's component
*/ */
operations: ItemOperation[]; operations$: BehaviorSubject<ItemOperation[]> = new BehaviorSubject<ItemOperation[]>([]);
/** /**
* The keys of the actions (to loop over) * The keys of the actions (to loop over)
*/ */
actionsKeys; actionsKeys;
constructor(private route: ActivatedRoute) { constructor(private route: ActivatedRoute,
private authorizationService: AuthorizationDataService) {
} }
ngOnInit(): void { ngOnInit(): void {
@@ -67,21 +72,43 @@ export class ItemStatusComponent implements OnInit {
i18n example: 'item.edit.tabs.status.buttons.<key>.label' i18n example: 'item.edit.tabs.status.buttons.<key>.label'
The value is supposed to be a href for the button The value is supposed to be a href for the button
*/ */
this.operations = []; const operations = [];
this.operations.push(new ItemOperation('authorizations', this.getCurrentUrl(item) + '/authorizations')); operations.push(new ItemOperation('authorizations', this.getCurrentUrl(item) + '/authorizations'));
this.operations.push(new ItemOperation('mappedCollections', this.getCurrentUrl(item) + '/mapper')); operations.push(new ItemOperation('mappedCollections', this.getCurrentUrl(item) + '/mapper'));
if (item.isWithdrawn) { operations.push(undefined);
this.operations.push(new ItemOperation('reinstate', this.getCurrentUrl(item) + '/reinstate')); // Store the index of the "withdraw" or "reinstate" operation, because it's added asynchronously
} else { const indexOfWithdrawReinstate = operations.length - 1;
this.operations.push(new ItemOperation('withdraw', this.getCurrentUrl(item) + '/withdraw'));
}
if (item.isDiscoverable) { if (item.isDiscoverable) {
this.operations.push(new ItemOperation('private', this.getCurrentUrl(item) + '/private')); operations.push(new ItemOperation('private', this.getCurrentUrl(item) + '/private'));
} else { } else {
this.operations.push(new ItemOperation('public', this.getCurrentUrl(item) + '/public')); operations.push(new ItemOperation('public', this.getCurrentUrl(item) + '/public'));
}
operations.push(new ItemOperation('delete', this.getCurrentUrl(item) + '/delete'));
operations.push(new ItemOperation('move', this.getCurrentUrl(item) + '/move'));
this.operations$.next(operations);
if (item.isWithdrawn) {
this.authorizationService.isAuthorized(FeatureID.ReinstateItem, item.self).pipe(distinctUntilChanged()).subscribe((authorized) => {
const newOperations = [...this.operations$.value];
if (authorized) {
newOperations[indexOfWithdrawReinstate] = new ItemOperation('reinstate', this.getCurrentUrl(item) + '/reinstate');
} else {
newOperations[indexOfWithdrawReinstate] = undefined;
}
this.operations$.next(newOperations);
});
} else {
this.authorizationService.isAuthorized(FeatureID.WithdrawItem, item.self).pipe(distinctUntilChanged()).subscribe((authorized) => {
const newOperations = [...this.operations$.value];
if (authorized) {
newOperations[indexOfWithdrawReinstate] = new ItemOperation('withdraw', this.getCurrentUrl(item) + '/withdraw');
} else {
newOperations[indexOfWithdrawReinstate] = undefined;
}
this.operations$.next(newOperations);
});
} }
this.operations.push(new ItemOperation('delete', this.getCurrentUrl(item) + '/delete'));
this.operations.push(new ItemOperation('move', this.getCurrentUrl(item) + '/move'));
}); });
} }
@@ -102,4 +129,8 @@ export class ItemStatusComponent implements OnInit {
return getItemEditRoute(item.id); return getItemEditRoute(item.id);
} }
trackOperation(index: number, operation: ItemOperation) {
return hasValue(operation) ? operation.operationKey : undefined;
}
} }

View File

@@ -0,0 +1,30 @@
import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router';
import { AuthorizationDataService } from '../core/data/feature-authorization/authorization-data.service';
import { ItemPageResolver } from './item-page.resolver';
import { Item } from '../core/shared/item.model';
import { DsoPageFeatureGuard } from '../core/data/feature-authorization/feature-authorization-guard/dso-page-feature.guard';
import { Observable } from 'rxjs/internal/Observable';
import { FeatureID } from '../core/data/feature-authorization/feature-id';
import { of as observableOf } from 'rxjs';
@Injectable({
providedIn: 'root'
})
/**
* Guard for preventing unauthorized access to certain {@link Item} pages requiring administrator rights
*/
export class ItemPageAdministratorGuard extends DsoPageFeatureGuard<Item> {
constructor(protected resolver: ItemPageResolver,
protected authorizationService: AuthorizationDataService,
protected router: Router) {
super(resolver, authorizationService, router);
}
/**
* Check administrator authorization rights
*/
getFeatureID(next: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<FeatureID> {
return observableOf(FeatureID.AdministratorOf);
}
}

View File

@@ -10,6 +10,7 @@ import { DSOBreadcrumbsService } from '../core/breadcrumbs/dso-breadcrumbs.servi
import { LinkService } from '../core/cache/builders/link.service'; import { LinkService } from '../core/cache/builders/link.service';
import { UploadBitstreamComponent } from './bitstreams/upload/upload-bitstream.component'; import { UploadBitstreamComponent } from './bitstreams/upload/upload-bitstream.component';
import { UPLOAD_BITSTREAM_PATH, ITEM_EDIT_PATH } from './item-page-routing-paths'; import { UPLOAD_BITSTREAM_PATH, ITEM_EDIT_PATH } from './item-page-routing-paths';
import { ItemPageAdministratorGuard } from './item-page-administrator.guard';
@NgModule({ @NgModule({
imports: [ imports: [
@@ -34,7 +35,7 @@ import { UPLOAD_BITSTREAM_PATH, ITEM_EDIT_PATH } from './item-page-routing-paths
{ {
path: ITEM_EDIT_PATH, path: ITEM_EDIT_PATH,
loadChildren: './edit-item-page/edit-item-page.module#EditItemPageModule', loadChildren: './edit-item-page/edit-item-page.module#EditItemPageModule',
canActivate: [AuthenticatedGuard] canActivate: [ItemPageAdministratorGuard]
}, },
{ {
path: UPLOAD_BITSTREAM_PATH, path: UPLOAD_BITSTREAM_PATH,
@@ -49,7 +50,8 @@ import { UPLOAD_BITSTREAM_PATH, ITEM_EDIT_PATH } from './item-page-routing-paths
ItemPageResolver, ItemPageResolver,
ItemBreadcrumbResolver, ItemBreadcrumbResolver,
DSOBreadcrumbsService, DSOBreadcrumbsService,
LinkService LinkService,
ItemPageAdministratorGuard
] ]
}) })

View File

@@ -21,6 +21,7 @@ import { COMMUNITY_MODULE_PATH } from './+community-page/community-page-routing-
import { ITEM_MODULE_PATH } from './+item-page/item-page-routing-paths'; import { ITEM_MODULE_PATH } from './+item-page/item-page-routing-paths';
import { ReloadGuard } from './core/reload/reload.guard'; import { ReloadGuard } from './core/reload/reload.guard';
import { EndUserAgreementCurrentUserGuard } from './core/end-user-agreement/end-user-agreement-current-user.guard'; import { EndUserAgreementCurrentUserGuard } from './core/end-user-agreement/end-user-agreement-current-user.guard';
import { SiteRegisterGuard } from './core/data/feature-authorization/feature-authorization-guard/site-register.guard';
@NgModule({ @NgModule({
imports: [ imports: [
@@ -33,7 +34,7 @@ import { EndUserAgreementCurrentUserGuard } from './core/end-user-agreement/end-
{ path: 'community-list', loadChildren: './community-list-page/community-list-page.module#CommunityListPageModule', canActivate: [EndUserAgreementCurrentUserGuard] }, { path: 'community-list', loadChildren: './community-list-page/community-list-page.module#CommunityListPageModule', canActivate: [EndUserAgreementCurrentUserGuard] },
{ path: 'id', loadChildren: './+lookup-by-id/lookup-by-id.module#LookupIdModule', canActivate: [EndUserAgreementCurrentUserGuard] }, { path: 'id', loadChildren: './+lookup-by-id/lookup-by-id.module#LookupIdModule', canActivate: [EndUserAgreementCurrentUserGuard] },
{ path: 'handle', loadChildren: './+lookup-by-id/lookup-by-id.module#LookupIdModule', canActivate: [EndUserAgreementCurrentUserGuard] }, { path: 'handle', loadChildren: './+lookup-by-id/lookup-by-id.module#LookupIdModule', canActivate: [EndUserAgreementCurrentUserGuard] },
{ path: REGISTER_PATH, loadChildren: './register-page/register-page.module#RegisterPageModule' }, { path: REGISTER_PATH, loadChildren: './register-page/register-page.module#RegisterPageModule', canActivate: [SiteRegisterGuard] },
{ path: FORGOT_PASSWORD_PATH, loadChildren: './forgot-password/forgot-password.module#ForgotPasswordModule', canActivate: [EndUserAgreementCurrentUserGuard] }, { path: FORGOT_PASSWORD_PATH, loadChildren: './forgot-password/forgot-password.module#ForgotPasswordModule', canActivate: [EndUserAgreementCurrentUserGuard] },
{ path: COMMUNITY_MODULE_PATH, loadChildren: './+community-page/community-page.module#CommunityPageModule', canActivate: [EndUserAgreementCurrentUserGuard] }, { path: COMMUNITY_MODULE_PATH, loadChildren: './+community-page/community-page.module#CommunityPageModule', canActivate: [EndUserAgreementCurrentUserGuard] },
{ path: COLLECTION_MODULE_PATH, loadChildren: './+collection-page/collection-page.module#CollectionPageModule', canActivate: [EndUserAgreementCurrentUserGuard] }, { path: COLLECTION_MODULE_PATH, loadChildren: './+collection-page/collection-page.module#CollectionPageModule', canActivate: [EndUserAgreementCurrentUserGuard] },

View File

@@ -170,6 +170,7 @@ import { ReloadGuard } from './reload/reload.guard';
import { EndUserAgreementCurrentUserGuard } from './end-user-agreement/end-user-agreement-current-user.guard'; import { EndUserAgreementCurrentUserGuard } from './end-user-agreement/end-user-agreement-current-user.guard';
import { EndUserAgreementCookieGuard } from './end-user-agreement/end-user-agreement-cookie.guard'; import { EndUserAgreementCookieGuard } from './end-user-agreement/end-user-agreement-cookie.guard';
import { EndUserAgreementService } from './end-user-agreement/end-user-agreement.service'; import { EndUserAgreementService } from './end-user-agreement/end-user-agreement.service';
import { SiteRegisterGuard } from './data/feature-authorization/feature-authorization-guard/site-register.guard';
/** /**
* When not in production, endpoint responses can be mocked for testing purposes * When not in production, endpoint responses can be mocked for testing purposes
@@ -292,6 +293,7 @@ const PROVIDERS = [
FeatureDataService, FeatureDataService,
AuthorizationDataService, AuthorizationDataService,
SiteAdministratorGuard, SiteAdministratorGuard,
SiteRegisterGuard,
MetadataSchemaDataService, MetadataSchemaDataService,
MetadataFieldDataService, MetadataFieldDataService,
TokenResponseParsingService, TokenResponseParsingService,

View File

@@ -63,33 +63,33 @@ describe('AuthorizationDataService', () => {
return Object.assign(new FindListOptions(), { searchParams }); return Object.assign(new FindListOptions(), { searchParams });
} }
describe('when no arguments are provided and a user is authenticated', () => { describe('when no arguments are provided', () => {
beforeEach(() => { beforeEach(() => {
service.searchByObject().subscribe(); service.searchByObject().subscribe();
}); });
it('should call searchBy with the site\'s url and authenticated user\'s uuid', () => { it('should call searchBy with the site\'s url', () => {
expect(service.searchBy).toHaveBeenCalledWith('object', createExpected(site.self, ePerson.uuid)); expect(service.searchBy).toHaveBeenCalledWith('object', createExpected(site.self));
}); });
}); });
describe('when no arguments except for a feature are provided and a user is authenticated', () => { describe('when no arguments except for a feature are provided', () => {
beforeEach(() => { beforeEach(() => {
service.searchByObject(FeatureID.LoginOnBehalfOf).subscribe(); service.searchByObject(FeatureID.LoginOnBehalfOf).subscribe();
}); });
it('should call searchBy with the site\'s url, authenticated user\'s uuid and the feature', () => { it('should call searchBy with the site\'s url and the feature', () => {
expect(service.searchBy).toHaveBeenCalledWith('object', createExpected(site.self, ePerson.uuid, FeatureID.LoginOnBehalfOf)); expect(service.searchBy).toHaveBeenCalledWith('object', createExpected(site.self, null, FeatureID.LoginOnBehalfOf));
}); });
}); });
describe('when a feature and object url are provided, but no user uuid and a user is authenticated', () => { describe('when a feature and object url are provided', () => {
beforeEach(() => { beforeEach(() => {
service.searchByObject(FeatureID.LoginOnBehalfOf, objectUrl).subscribe(); service.searchByObject(FeatureID.LoginOnBehalfOf, objectUrl).subscribe();
}); });
it('should call searchBy with the object\'s url, authenticated user\'s uuid and the feature', () => { it('should call searchBy with the object\'s url and the feature', () => {
expect(service.searchBy).toHaveBeenCalledWith('object', createExpected(objectUrl, ePerson.uuid, FeatureID.LoginOnBehalfOf)); expect(service.searchBy).toHaveBeenCalledWith('object', createExpected(objectUrl, null, FeatureID.LoginOnBehalfOf));
}); });
}); });
@@ -102,17 +102,6 @@ describe('AuthorizationDataService', () => {
expect(service.searchBy).toHaveBeenCalledWith('object', createExpected(objectUrl, ePersonUuid, FeatureID.LoginOnBehalfOf)); expect(service.searchBy).toHaveBeenCalledWith('object', createExpected(objectUrl, ePersonUuid, FeatureID.LoginOnBehalfOf));
}); });
}); });
describe('when no arguments are provided and no user is authenticated', () => {
beforeEach(() => {
spyOn(authService, 'isAuthenticated').and.returnValue(observableOf(false));
service.searchByObject().subscribe();
});
it('should call searchBy with the site\'s url', () => {
expect(service.searchBy).toHaveBeenCalledWith('object', createExpected(site.self));
});
});
}); });
describe('isAuthorized', () => { describe('isAuthorized', () => {

View File

@@ -25,7 +25,6 @@ import { hasValue, isNotEmpty } from '../../../shared/empty.util';
import { RequestParam } from '../../cache/models/request-param.model'; import { RequestParam } from '../../cache/models/request-param.model';
import { AuthorizationSearchParams } from './authorization-search-params'; import { AuthorizationSearchParams } from './authorization-search-params';
import { import {
addAuthenticatedUserUuidIfEmpty,
addSiteObjectUrlIfEmpty, addSiteObjectUrlIfEmpty,
oneAuthorizationMatchesFeature oneAuthorizationMatchesFeature
} from './authorization-utils'; } from './authorization-utils';
@@ -90,7 +89,6 @@ export class AuthorizationDataService extends DataService<Authorization> {
searchByObject(featureId?: FeatureID, objectUrl?: string, ePersonUuid?: string, options: FindListOptions = {}, ...linksToFollow: Array<FollowLinkConfig<Authorization>>): Observable<RemoteData<PaginatedList<Authorization>>> { searchByObject(featureId?: FeatureID, objectUrl?: string, ePersonUuid?: string, options: FindListOptions = {}, ...linksToFollow: Array<FollowLinkConfig<Authorization>>): Observable<RemoteData<PaginatedList<Authorization>>> {
return observableOf(new AuthorizationSearchParams(objectUrl, ePersonUuid, featureId)).pipe( return observableOf(new AuthorizationSearchParams(objectUrl, ePersonUuid, featureId)).pipe(
addSiteObjectUrlIfEmpty(this.siteService), addSiteObjectUrlIfEmpty(this.siteService),
addAuthenticatedUserUuidIfEmpty(this.authService),
switchMap((params: AuthorizationSearchParams) => { switchMap((params: AuthorizationSearchParams) => {
return this.searchBy(this.searchByObjectPath, this.createSearchOptions(params.objectUrl, options, params.ePersonUuid, params.featureId), ...linksToFollow); return this.searchBy(this.searchByObjectPath, this.createSearchOptions(params.objectUrl, options, params.ePersonUuid, params.featureId), ...linksToFollow);
}) })

View File

@@ -0,0 +1,63 @@
import { AuthorizationDataService } from '../authorization-data.service';
import { ActivatedRouteSnapshot, Resolve, Router, RouterStateSnapshot } from '@angular/router';
import { RemoteData } from '../../remote-data';
import { of as observableOf } from 'rxjs';
import { createSuccessfulRemoteDataObject$ } from '../../../../shared/remote-data.utils';
import { DSpaceObject } from '../../../shared/dspace-object.model';
import { DsoPageFeatureGuard } from './dso-page-feature.guard';
import { FeatureID } from '../feature-id';
import { Observable } from 'rxjs/internal/Observable';
/**
* Test implementation of abstract class DsoPageAdministratorGuard
*/
class DsoPageFeatureGuardImpl extends DsoPageFeatureGuard<any> {
constructor(protected resolver: Resolve<RemoteData<any>>,
protected authorizationService: AuthorizationDataService,
protected router: Router,
protected featureID: FeatureID) {
super(resolver, authorizationService, router);
}
getFeatureID(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<FeatureID> {
return observableOf(this.featureID);
}
}
describe('DsoPageAdministratorGuard', () => {
let guard: DsoPageFeatureGuard<any>;
let authorizationService: AuthorizationDataService;
let router: Router;
let resolver: Resolve<RemoteData<any>>;
let object: DSpaceObject;
function init() {
object = {
self: 'test-selflink'
} as DSpaceObject;
authorizationService = jasmine.createSpyObj('authorizationService', {
isAuthorized: observableOf(true)
});
router = jasmine.createSpyObj('router', {
parseUrl: {}
});
resolver = jasmine.createSpyObj('resolver', {
resolve: createSuccessfulRemoteDataObject$(object)
});
guard = new DsoPageFeatureGuardImpl(resolver, authorizationService, router, undefined);
}
beforeEach(() => {
init();
});
describe('getObjectUrl', () => {
it('should return the resolved object\'s selflink', (done) => {
guard.getObjectUrl(undefined, undefined).subscribe((selflink) => {
expect(selflink).toEqual(object.self);
done();
});
});
});
});

View File

@@ -0,0 +1,30 @@
import { ActivatedRouteSnapshot, Resolve, Router, RouterStateSnapshot } from '@angular/router';
import { RemoteData } from '../../remote-data';
import { AuthorizationDataService } from '../authorization-data.service';
import { Observable } from 'rxjs/internal/Observable';
import { getAllSucceededRemoteDataPayload } from '../../../shared/operators';
import { map } from 'rxjs/operators';
import { DSpaceObject } from '../../../shared/dspace-object.model';
import { FeatureAuthorizationGuard } from './feature-authorization.guard';
/**
* Abstract Guard for preventing unauthorized access to {@link DSpaceObject} pages that require rights for a specific feature
* This guard utilizes a resolver to retrieve the relevant object to check authorizations for
*/
export abstract class DsoPageFeatureGuard<T extends DSpaceObject> extends FeatureAuthorizationGuard {
constructor(protected resolver: Resolve<RemoteData<T>>,
protected authorizationService: AuthorizationDataService,
protected router: Router) {
super(authorizationService, router);
}
/**
* Check authorization rights for the object resolved using the provided resolver
*/
getObjectUrl(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<string> {
return (this.resolver.resolve(route, state) as Observable<RemoteData<T>>).pipe(
getAllSucceededRemoteDataPayload(),
map((dso) => dso.self)
);
}
}

View File

@@ -2,7 +2,8 @@ import { FeatureAuthorizationGuard } from './feature-authorization.guard';
import { AuthorizationDataService } from '../authorization-data.service'; import { AuthorizationDataService } from '../authorization-data.service';
import { FeatureID } from '../feature-id'; import { FeatureID } from '../feature-id';
import { of as observableOf } from 'rxjs'; import { of as observableOf } from 'rxjs';
import { Router } from '@angular/router'; import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router';
import { Observable } from 'rxjs/internal/Observable';
/** /**
* Test implementation of abstract class FeatureAuthorizationGuard * Test implementation of abstract class FeatureAuthorizationGuard
@@ -17,16 +18,16 @@ class FeatureAuthorizationGuardImpl extends FeatureAuthorizationGuard {
super(authorizationService, router); super(authorizationService, router);
} }
getFeatureID(): FeatureID { getFeatureID(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<FeatureID> {
return this.featureId; return observableOf(this.featureId);
} }
getObjectUrl(): string { getObjectUrl(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<string> {
return this.objectUrl; return observableOf(this.objectUrl);
} }
getEPersonUuid(): string { getEPersonUuid(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<string> {
return this.ePersonUuid; return observableOf(this.ePersonUuid);
} }
} }

View File

@@ -9,6 +9,8 @@ import { AuthorizationDataService } from '../authorization-data.service';
import { FeatureID } from '../feature-id'; import { FeatureID } from '../feature-id';
import { Observable } from 'rxjs/internal/Observable'; import { Observable } from 'rxjs/internal/Observable';
import { returnUnauthorizedUrlTreeOnFalse } from '../../../shared/operators'; import { returnUnauthorizedUrlTreeOnFalse } from '../../../shared/operators';
import { combineLatest as observableCombineLatest, of as observableOf } from 'rxjs';
import { switchMap } from 'rxjs/operators';
/** /**
* Abstract Guard for preventing unauthorized activating and loading of routes when a user * Abstract Guard for preventing unauthorized activating and loading of routes when a user
@@ -24,29 +26,32 @@ export abstract class FeatureAuthorizationGuard implements CanActivate {
* True when user has authorization rights for the feature and object provided * True when user has authorization rights for the feature and object provided
* Redirect the user to the unauthorized page when he/she's not authorized for the given feature * Redirect the user to the unauthorized page when he/she's not authorized for the given feature
*/ */
canActivate(next: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<boolean | UrlTree> { canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<boolean | UrlTree> {
return this.authorizationService.isAuthorized(this.getFeatureID(), this.getObjectUrl(), this.getEPersonUuid()).pipe(returnUnauthorizedUrlTreeOnFalse(this.router)); return observableCombineLatest(this.getFeatureID(route, state), this.getObjectUrl(route, state), this.getEPersonUuid(route, state)).pipe(
switchMap(([featureID, objectUrl, ePersonUuid]) => this.authorizationService.isAuthorized(featureID, objectUrl, ePersonUuid)),
returnUnauthorizedUrlTreeOnFalse(this.router)
);
} }
/** /**
* The type of feature to check authorization for * The type of feature to check authorization for
* Override this method to define a feature * Override this method to define a feature
*/ */
abstract getFeatureID(): FeatureID; abstract getFeatureID(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<FeatureID>;
/** /**
* The URL of the object to check if the user has authorized rights for * The URL of the object to check if the user has authorized rights for
* Override this method to define an object URL. If not provided, the {@link Site}'s URL will be used * Override this method to define an object URL. If not provided, the {@link Site}'s URL will be used
*/ */
getObjectUrl(): string { getObjectUrl(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<string> {
return undefined; return observableOf(undefined);
} }
/** /**
* The UUID of the user to check authorization rights for * The UUID of the user to check authorization rights for
* Override this method to define an {@link EPerson} UUID. If not provided, the authenticated user's UUID will be used. * Override this method to define an {@link EPerson} UUID. If not provided, the authenticated user's UUID will be used.
*/ */
getEPersonUuid(): string { getEPersonUuid(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<string> {
return undefined; return observableOf(undefined);
} }
} }

View File

@@ -2,7 +2,9 @@ import { Injectable } from '@angular/core';
import { FeatureAuthorizationGuard } from './feature-authorization.guard'; import { FeatureAuthorizationGuard } from './feature-authorization.guard';
import { FeatureID } from '../feature-id'; import { FeatureID } from '../feature-id';
import { AuthorizationDataService } from '../authorization-data.service'; import { AuthorizationDataService } from '../authorization-data.service';
import { Router } from '@angular/router'; import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router';
import { of as observableOf } from 'rxjs';
import { Observable } from 'rxjs/internal/Observable';
/** /**
* Prevent unauthorized activating and loading of routes when the current authenticated user doesn't have administrator * Prevent unauthorized activating and loading of routes when the current authenticated user doesn't have administrator
@@ -19,7 +21,7 @@ export class SiteAdministratorGuard extends FeatureAuthorizationGuard {
/** /**
* Check administrator authorization rights * Check administrator authorization rights
*/ */
getFeatureID(): FeatureID { getFeatureID(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<FeatureID> {
return FeatureID.AdministratorOf; return observableOf(FeatureID.AdministratorOf);
} }
} }

View File

@@ -0,0 +1,27 @@
import { FeatureAuthorizationGuard } from './feature-authorization.guard';
import { Injectable } from '@angular/core';
import { AuthorizationDataService } from '../authorization-data.service';
import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router';
import { Observable } from 'rxjs/internal/Observable';
import { FeatureID } from '../feature-id';
import { of as observableOf } from 'rxjs';
/**
* Prevent unauthorized activating and loading of routes when the current authenticated user doesn't have registration
* rights to the {@link Site}
*/
@Injectable({
providedIn: 'root'
})
export class SiteRegisterGuard extends FeatureAuthorizationGuard {
constructor(protected authorizationService: AuthorizationDataService, protected router: Router) {
super(authorizationService, router);
}
/**
* Check registration authorization rights
*/
getFeatureID(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<FeatureID> {
return observableOf(FeatureID.EPersonRegistration);
}
}

View File

@@ -3,5 +3,8 @@
*/ */
export enum FeatureID { export enum FeatureID {
LoginOnBehalfOf = 'loginOnBehalfOf', LoginOnBehalfOf = 'loginOnBehalfOf',
AdministratorOf = 'administratorOf' AdministratorOf = 'administratorOf',
WithdrawItem = 'withdrawItem',
ReinstateItem = 'reinstateItem',
EPersonRegistration = 'epersonRegistration',
} }

View File

@@ -8,6 +8,6 @@
</ng-container> </ng-container>
<div class="dropdown-divider"></div> <div class="dropdown-divider"></div>
<a class="dropdown-item" [routerLink]="[getRegisterRoute()]">{{"login.form.new-user" | translate}}</a> <a class="dropdown-item" *ngIf="canRegister$ | async" [routerLink]="[getRegisterRoute()]">{{"login.form.new-user" | translate}}</a>
<a class="dropdown-item" [routerLink]="[getForgotRoute()]">{{"login.form.forgot-password" | translate}}</a> <a class="dropdown-item" [routerLink]="[getForgotRoute()]">{{"login.form.forgot-password" | translate}}</a>
</div> </div>

View File

@@ -19,6 +19,8 @@ import { provideMockStore } from '@ngrx/store/testing';
import { createTestComponent } from '../testing/utils.test'; import { createTestComponent } from '../testing/utils.test';
import { RouterTestingModule } from '@angular/router/testing'; import { RouterTestingModule } from '@angular/router/testing';
import { HardRedirectService } from '../../core/services/hard-redirect.service'; import { HardRedirectService } from '../../core/services/hard-redirect.service';
import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service';
import { of } from 'rxjs/internal/observable/of';
describe('LogInComponent', () => { describe('LogInComponent', () => {
@@ -36,11 +38,17 @@ describe('LogInComponent', () => {
}; };
let hardRedirectService: HardRedirectService; let hardRedirectService: HardRedirectService;
let authorizationService: AuthorizationDataService;
beforeEach(async(() => { beforeEach(async(() => {
hardRedirectService = jasmine.createSpyObj('hardRedirectService', { hardRedirectService = jasmine.createSpyObj('hardRedirectService', {
redirect: {}, redirect: {},
getCurrentRoute: {} getCurrentRoute: {}
}); });
authorizationService = jasmine.createSpyObj('authorizationService', {
isAuthorized: of(true)
});
// refine the test module by declaring the test component // refine the test module by declaring the test component
TestBed.configureTestingModule({ TestBed.configureTestingModule({
imports: [ imports: [
@@ -65,6 +73,7 @@ describe('LogInComponent', () => {
// { provide: Router, useValue: new RouterStub() }, // { provide: Router, useValue: new RouterStub() },
{ provide: ActivatedRoute, useValue: new ActivatedRouteStub() }, { provide: ActivatedRoute, useValue: new ActivatedRouteStub() },
{ provide: HardRedirectService, useValue: hardRedirectService }, { provide: HardRedirectService, useValue: hardRedirectService },
{ provide: AuthorizationDataService, useValue: authorizationService },
provideMockStore({ initialState }), provideMockStore({ initialState }),
LogInComponent LogInComponent
], ],

View File

@@ -12,6 +12,8 @@ import { CoreState } from '../../core/core.reducers';
import { getForgotPasswordRoute, getRegisterRoute } from '../../app-routing-paths'; import { getForgotPasswordRoute, getRegisterRoute } from '../../app-routing-paths';
import { hasValue } from '../empty.util'; import { hasValue } from '../empty.util';
import { AuthService } from '../../core/auth/auth.service'; import { AuthService } from '../../core/auth/auth.service';
import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service';
import { FeatureID } from '../../core/data/feature-authorization/feature-id';
/** /**
* /users/sign-in * /users/sign-in
@@ -48,8 +50,14 @@ export class LogInComponent implements OnInit {
*/ */
public loading: Observable<boolean>; public loading: Observable<boolean>;
/**
* Whether or not the current user (or anonymous) is authorized to register an account
*/
canRegister$: Observable<boolean>;
constructor(private store: Store<CoreState>, constructor(private store: Store<CoreState>,
private authService: AuthService) { private authService: AuthService,
private authorizationService: AuthorizationDataService) {
} }
ngOnInit(): void { ngOnInit(): void {
@@ -70,6 +78,8 @@ export class LogInComponent implements OnInit {
this.authService.clearRedirectUrl(); this.authService.clearRedirectUrl();
} }
}); });
this.canRegister$ = this.authorizationService.isAuthorized(FeatureID.EPersonRegistration);
} }
getRegisterRoute() { getRegisterRoute() {