Merge remote-tracking branch 'remotes/origin/main' into comcol-resource-policies

This commit is contained in:
Giuseppe Digilio
2020-12-04 10:52:30 +01:00
58 changed files with 796 additions and 204 deletions

View File

@@ -13,12 +13,12 @@ import { GROUP_EDIT_PATH } from './admin-access-control-routing-paths';
{
path: `${GROUP_EDIT_PATH}/:groupId`,
component: GroupFormComponent,
data: {title: 'admin.registries.schema.title'}
data: {title: 'admin.access-control.groups.title.singleGroup'}
},
{
path: `${GROUP_EDIT_PATH}/newGroup`,
component: GroupFormComponent,
data: {title: 'admin.registries.schema.title'}
data: {title: 'admin.access-control.groups.title.addGroup'}
},
])
]

View File

@@ -40,7 +40,7 @@
</form>
<ds-pagination
*ngIf="(ePeopleDto$ | async)?.totalElements > 0"
*ngIf="(pageInfoState$ | async)?.totalElements > 0"
[paginationOptions]="config"
[pageInfoState]="pageInfoState$"
[collectionSize]="(pageInfoState$ | async)?.totalElements"

View File

@@ -44,7 +44,7 @@ describe('EPeopleRegistryComponent', () => {
activeEPerson: null,
allEpeople: mockEPeople,
getEPeople(): Observable<RemoteData<PaginatedList<EPerson>>> {
return createSuccessfulRemoteDataObject$(new PaginatedList(null, this.allEpeople));
return createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo({ elementsPerPage: this.allEpeople.length, totalElements: this.allEpeople.length, totalPages: 1, currentPage: 1 }), this.allEpeople));
},
getActiveEPerson(): Observable<EPerson> {
return observableOf(this.activeEPerson);
@@ -54,18 +54,18 @@ describe('EPeopleRegistryComponent', () => {
const result = this.allEpeople.find((ePerson: EPerson) => {
return ePerson.email === query
});
return createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), [result]));
return createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo({ elementsPerPage: [result].length, totalElements: [result].length, totalPages: 1, currentPage: 1 }), [result]));
}
if (scope === 'metadata') {
if (query === '') {
return createSuccessfulRemoteDataObject$(new PaginatedList(null, this.allEpeople));
return createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo({ elementsPerPage: this.allEpeople.length, totalElements: this.allEpeople.length, totalPages: 1, currentPage: 1 }), this.allEpeople));
}
const result = this.allEpeople.find((ePerson: EPerson) => {
return (ePerson.name.includes(query) || ePerson.email.includes(query))
});
return createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), [result]));
return createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo({ elementsPerPage: [result].length, totalElements: [result].length, totalPages: 1, currentPage: 1 }), [result]));
}
return createSuccessfulRemoteDataObject$(new PaginatedList(null, this.allEpeople));
return createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo({ elementsPerPage: this.allEpeople.length, totalElements: this.allEpeople.length, totalPages: 1, currentPage: 1 }), this.allEpeople));
},
deleteEPerson(ePerson: EPerson): Observable<boolean> {
this.allEpeople = this.allEpeople.filter((ePerson2: EPerson) => {

View File

@@ -141,7 +141,8 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy {
currentPage: this.config.currentPage,
elementsPerPage: this.config.pageSize
}).subscribe((peopleRD) => {
this.ePeople$.next(peopleRD)
this.ePeople$.next(peopleRD);
this.pageInfoState$.next(peopleRD.payload.pageInfo);
}
));

View File

@@ -1,6 +1,11 @@
<div class="container">
<div class="group-form row">
<div class="col-12">
<ds-alert *ngIf="groupBeingEdited?.permanent" [type]="AlertTypeEnum.Warning"
[content]="messagePrefix + '.alert.permanent'"></ds-alert>
<ds-alert *ngIf="!(canEdit$ | async) && (groupDataService.getActiveGroup() | async)" [type]="AlertTypeEnum.Warning"
[content]="(messagePrefix + '.alert.workflowGroup' | translate:{ name: (getLinkedDSO(groupBeingEdited) | async)?.payload?.name, comcol: (getLinkedDSO(groupBeingEdited) | async)?.payload?.type, comcolEditRolesRoute: (getLinkedEditRolesRoute(groupBeingEdited) | async) })">
</ds-alert>
<div *ngIf="groupDataService.getActiveGroup() | async; then editheader; else createHeader"></div>
@@ -18,10 +23,18 @@
[formLayout]="formLayout"
(cancel)="onCancel()"
(submitForm)="onSubmit()">
<div *ngIf="groupBeingEdited != null" class="row">
<button class="btn btn-light delete-button" [disabled]="!(canEdit$ | async) || groupBeingEdited.permanent"
(click)="delete()">
<i class="fa fa-trash"></i> {{ messagePrefix + '.actions.delete' | translate}}
</button>
</div>
</ds-form>
<ds-members-list *ngIf="groupBeingEdited != null" [messagePrefix]="messagePrefix + '.members-list'"></ds-members-list>
<ds-subgroups-list *ngIf="groupBeingEdited != null" [messagePrefix]="messagePrefix + '.subgroups-list'"></ds-subgroups-list>
<ds-members-list *ngIf="groupBeingEdited != null"
[messagePrefix]="messagePrefix + '.members-list'"></ds-members-list>
<ds-subgroups-list *ngIf="groupBeingEdited != null"
[messagePrefix]="messagePrefix + '.subgroups-list'"></ds-subgroups-list>
<div>
<button [routerLink]="[this.groupDataService.getGroupRegistryRouterLink()]"

View File

@@ -14,11 +14,14 @@ import { RemoteDataBuildService } from '../../../../core/cache/builders/remote-d
import { ObjectCacheService } from '../../../../core/cache/object-cache.service';
import { RestResponse } from '../../../../core/cache/response.models';
import { DSOChangeAnalyzer } from '../../../../core/data/dso-change-analyzer.service';
import { DSpaceObjectDataService } from '../../../../core/data/dspace-object-data.service';
import { AuthorizationDataService } from '../../../../core/data/feature-authorization/authorization-data.service';
import { PaginatedList } from '../../../../core/data/paginated-list';
import { RemoteData } from '../../../../core/data/remote-data';
import { EPersonDataService } from '../../../../core/eperson/eperson-data.service';
import { GroupDataService } from '../../../../core/eperson/group-data.service';
import { Group } from '../../../../core/eperson/models/group.model';
import { DSpaceObject } from '../../../../core/shared/dspace-object.model';
import { HALEndpointService } from '../../../../core/shared/hal-endpoint.service';
import { PageInfo } from '../../../../core/shared/page-info.model';
import { UUIDService } from '../../../../core/shared/uuid.service';
@@ -40,6 +43,9 @@ describe('GroupFormComponent', () => {
let builderService: FormBuilderService;
let ePersonDataServiceStub: any;
let groupsDataServiceStub: any;
let dsoDataServiceStub: any;
let authorizationService: AuthorizationDataService;
let notificationService: NotificationsServiceStub;
let router;
let groups;
@@ -74,6 +80,9 @@ describe('GroupFormComponent', () => {
editGroup(group: Group) {
this.activeGroup = group
},
updateGroup(group: Group) {
return null;
},
cancelEditGroup(): void {
this.activeGroup = null;
},
@@ -88,9 +97,18 @@ describe('GroupFormComponent', () => {
return createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), []))
}
};
authorizationService = jasmine.createSpyObj('authorizationService', {
isAuthorized: observableOf(true)
});
dsoDataServiceStub = {
findByHref(href: string): Observable<RemoteData<DSpaceObject>> {
return null;
}
}
builderService = getMockFormBuilderService();
translateService = getMockTranslateService();
router = new RouterMock();
notificationService = new NotificationsServiceStub();
TestBed.configureTestingModule({
imports: [CommonModule, NgbModule, FormsModule, ReactiveFormsModule, BrowserModule,
TranslateModule.forRoot({
@@ -104,7 +122,8 @@ describe('GroupFormComponent', () => {
providers: [GroupFormComponent,
{ provide: EPersonDataService, useValue: ePersonDataServiceStub },
{ provide: GroupDataService, useValue: groupsDataServiceStub },
{ provide: NotificationsService, useValue: new NotificationsServiceStub() },
{ provide: DSpaceObjectDataService, useValue: dsoDataServiceStub },
{ provide: NotificationsService, useValue: notificationService },
{ provide: FormBuilderService, useValue: builderService },
{ provide: DSOChangeAnalyzer, useValue: {} },
{ provide: HttpClient, useValue: {} },
@@ -115,6 +134,7 @@ describe('GroupFormComponent', () => {
{ provide: HALEndpointService, useValue: {} },
{ provide: ActivatedRoute, useValue: { data: observableOf({ dso: { payload: {} } }), params: observableOf({}) } },
{ provide: Router, useValue: router },
{ provide: AuthorizationDataService, useValue: authorizationService },
],
schemas: [NO_ERRORS_SCHEMA]
}).compileComponents();
@@ -148,6 +168,34 @@ describe('GroupFormComponent', () => {
});
}));
});
describe('with active Group', () => {
beforeEach(() => {
spyOn(groupsDataServiceStub, 'getActiveGroup').and.returnValue(observableOf(expected));
spyOn(groupsDataServiceStub, 'updateGroup').and.returnValue(observableOf(new RestResponse(true, 200, 'OK')));
component.groupName.value = 'newGroupName';
component.onSubmit();
fixture.detectChanges();
});
it('should emit the existing group using the correct new values', async(() => {
const expected2 = Object.assign(new Group(), {
name: 'newGroupName',
metadata: {
'dc.description': [
{
value: groupDescription
}
],
},
});
fixture.whenStable().then(() => {
expect(component.submitForm.emit).toHaveBeenCalledWith(expected2);
});
}));
it('should emit success notification', () => {
expect(notificationService.success).toHaveBeenCalled();
})
});
});
});

View File

@@ -1,6 +1,7 @@
import { Component, EventEmitter, HostListener, OnDestroy, OnInit, Output } from '@angular/core';
import { FormGroup } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import {
DynamicFormControlModel,
DynamicFormLayout,
@@ -8,18 +9,30 @@ import {
DynamicTextAreaModel
} from '@ng-dynamic-forms/core';
import { TranslateService } from '@ngx-translate/core';
import { combineLatest } from 'rxjs/internal/observable/combineLatest';
import { Subscription } from 'rxjs/internal/Subscription';
import { take } from 'rxjs/operators';
import { ObservedValueOf, combineLatest as observableCombineLatest, Observable, of as observableOf, Subscription } from 'rxjs';
import { catchError, map, switchMap, take } from 'rxjs/operators';
import { getCollectionEditRolesRoute } from '../../../../+collection-page/collection-page-routing-paths';
import { getCommunityEditRolesRoute } from '../../../../+community-page/community-page-routing-paths';
import { RestResponse } from '../../../../core/cache/response.models';
import { DSpaceObjectDataService } from '../../../../core/data/dspace-object-data.service';
import { AuthorizationDataService } from '../../../../core/data/feature-authorization/authorization-data.service';
import { FeatureID } from '../../../../core/data/feature-authorization/feature-id';
import { PaginatedList } from '../../../../core/data/paginated-list';
import { RemoteData } from '../../../../core/data/remote-data';
import { RequestService } from '../../../../core/data/request.service';
import { EPersonDataService } from '../../../../core/eperson/eperson-data.service';
import { GroupDataService } from '../../../../core/eperson/group-data.service';
import { Group } from '../../../../core/eperson/models/group.model';
import { Collection } from '../../../../core/shared/collection.model';
import { Community } from '../../../../core/shared/community.model';
import { DSpaceObject } from '../../../../core/shared/dspace-object.model';
import { getRemoteDataPayload, getSucceededRemoteData } from '../../../../core/shared/operators';
import { AlertType } from '../../../../shared/alert/aletr-type';
import { ConfirmationModalComponent } from '../../../../shared/confirmation-modal/confirmation-modal.component';
import { hasValue, isNotEmpty } from '../../../../shared/empty.util';
import { FormBuilderService } from '../../../../shared/form/builder/form-builder.service';
import { NotificationsService } from '../../../../shared/notifications/notifications.service';
import { followLink } from '../../../../shared/utils/follow-link-config.model';
@Component({
selector: 'ds-group-form',
@@ -89,22 +102,51 @@ export class GroupFormComponent implements OnInit, OnDestroy {
*/
groupBeingEdited: Group;
/**
* Observable whether or not the logged in user is allowed to delete the Group & doesn't have a linked object (community / collection linked to workspace group
*/
canEdit$: Observable<boolean>;
/**
* The AlertType enumeration
* @type {AlertType}
*/
public AlertTypeEnum = AlertType;
constructor(public groupDataService: GroupDataService,
private ePersonDataService: EPersonDataService,
private dSpaceObjectDataService: DSpaceObjectDataService,
private formBuilderService: FormBuilderService,
private translateService: TranslateService,
private notificationsService: NotificationsService,
private route: ActivatedRoute,
protected router: Router) {
protected router: Router,
private authorizationService: AuthorizationDataService,
private modalService: NgbModal,
public requestService: RequestService) {
}
ngOnInit() {
this.initialisePage();
}
initialisePage() {
this.subs.push(this.route.params.subscribe((params) => {
this.setActiveGroup(params.groupId)
}));
combineLatest(
this.canEdit$ = this.groupDataService.getActiveGroup().pipe(
switchMap((group: Group) => {
return observableCombineLatest(
this.authorizationService.isAuthorized(FeatureID.CanDelete, hasValue(group) ? group.self : undefined),
this.hasLinkedDSO(group),
(isAuthorized: ObservedValueOf<Observable<boolean>>, hasLinkedDSO: ObservedValueOf<Observable<boolean>>) => {
return isAuthorized && !hasLinkedDSO;
})
})
);
observableCombineLatest(
this.translateService.get(`${this.messagePrefix}.groupName`),
this.translateService.get(`${this.messagePrefix}.groupDescription`),
this.translateService.get(`${this.messagePrefix}.groupDescription`)
).subscribe(([groupName, groupDescription]) => {
this.groupName = new DynamicInputModel({
id: 'groupName',
@@ -123,21 +165,26 @@ export class GroupFormComponent implements OnInit, OnDestroy {
});
this.formModel = [
this.groupName,
this.groupDescription
this.groupDescription,
];
this.formGroup = this.formBuilderService.createFormGroup(this.formModel);
this.subs.push(this.groupDataService.getActiveGroup().subscribe((activeGroup: Group) => {
if (activeGroup != null) {
this.groupBeingEdited = activeGroup;
this.formGroup.patchValue({
groupName: activeGroup != null ? activeGroup.name : '',
groupDescription: activeGroup != null ? activeGroup.firstMetadataValue('dc.description') : '',
});
if (activeGroup.permanent) {
this.formGroup.get('groupName').disable();
this.subs.push(
observableCombineLatest(
this.groupDataService.getActiveGroup(),
this.canEdit$
).subscribe(([activeGroup, canEdit]) => {
if (activeGroup != null) {
this.groupBeingEdited = activeGroup;
this.formGroup.patchValue({
groupName: activeGroup != null ? activeGroup.name : '',
groupDescription: activeGroup != null ? activeGroup.firstMetadataValue('dc.description') : '',
});
if (!canEdit || activeGroup.permanent) {
this.formGroup.disable();
}
}
}
}));
})
);
});
}
@@ -172,7 +219,7 @@ export class GroupFormComponent implements OnInit, OnDestroy {
if (group === null) {
this.createNewGroup(values);
} else {
this.editGroup(group, values);
this.editGroup(group);
}
}
);
@@ -193,6 +240,7 @@ export class GroupFormComponent implements OnInit, OnDestroy {
if (isNotEmpty(resp.resourceSelfLinks)) {
const groupSelfLink = resp.resourceSelfLinks[0];
this.setActiveGroupWithLink(groupSelfLink);
this.groupDataService.clearGroupsRequests();
this.router.navigateByUrl(this.groupDataService.getGroupEditPageRouterLinkWithID(this.groupDataService.getUUIDFromString(groupSelfLink)));
}
} else {
@@ -225,14 +273,32 @@ export class GroupFormComponent implements OnInit, OnDestroy {
}
/**
* // TODO
* @param group
* @param values
* Edit existing Group based on given values from form and old Group
* @param group Group to edit and old values contained within
*/
editGroup(group: Group, values) {
// TODO (backend)
console.log('TODO implement editGroup', values);
this.notificationsService.error('TODO implement editGroup (not yet implemented in backend) ');
editGroup(group: Group) {
const editedGroup = Object.assign(new Group(), {
id: group.id,
metadata: {
'dc.description': [
{
value: (hasValue(this.groupDescription.value) ? this.groupDescription.value : group.firstMetadataValue('dc.description'))
}
],
},
name: (hasValue(this.groupName.value) ? this.groupName.value : group.name),
_links: group._links,
});
const response = this.groupDataService.updateGroup(editedGroup);
response.pipe(take(1)).subscribe((restResponse: RestResponse) => {
if (restResponse.isSuccessful) {
this.notificationsService.success(this.translateService.get(this.messagePrefix + '.notification.edited.success', { name: editedGroup.name }));
this.submitForm.emit(editedGroup);
} else {
this.notificationsService.error(this.translateService.get(this.messagePrefix + '.notification.edited.failure', { name: editedGroup.name }));
this.cancelForm.emit();
}
});
}
/**
@@ -258,7 +324,7 @@ export class GroupFormComponent implements OnInit, OnDestroy {
this.groupDataService.getActiveGroup().pipe(take(1)).subscribe((activeGroup: Group) => {
if (activeGroup === null) {
this.groupDataService.cancelEditGroup();
this.groupDataService.findByHref(groupSelfLink)
this.groupDataService.findByHref(groupSelfLink, followLink('subgroups'), followLink('epersons'), followLink('object'))
.pipe(
getSucceededRemoteData(),
getRemoteDataPayload())
@@ -269,6 +335,48 @@ export class GroupFormComponent implements OnInit, OnDestroy {
});
}
/**
* Deletes the Group from the Repository. The Group will be the only that this form is showing.
* It'll either show a success or error message depending on whether the delete was successful or not.
*/
delete() {
this.groupDataService.getActiveGroup().pipe(take(1)).subscribe((group: Group) => {
const modalRef = this.modalService.open(ConfirmationModalComponent);
modalRef.componentInstance.dso = group;
modalRef.componentInstance.headerLabel = this.messagePrefix + '.delete-group.modal.header';
modalRef.componentInstance.infoLabel = this.messagePrefix + '.delete-group.modal.info';
modalRef.componentInstance.cancelLabel = this.messagePrefix + '.delete-group.modal.cancel';
modalRef.componentInstance.confirmLabel = this.messagePrefix + '.delete-group.modal.confirm';
modalRef.componentInstance.response.pipe(take(1)).subscribe((confirm: boolean) => {
if (confirm) {
if (hasValue(group.id)) {
this.groupDataService.deleteGroup(group).pipe(take(1))
.subscribe(([success, optionalErrorMessage]: [boolean, string]) => {
if (success) {
this.notificationsService.success(this.translateService.get(this.messagePrefix + '.notification.deleted.success', { name: group.name }));
this.reset();
} else {
this.notificationsService.error(
this.translateService.get(this.messagePrefix + '.notification.deleted.failure.title', { name: group.name }),
this.translateService.get(this.messagePrefix + '.notification.deleted.failure.content', { cause: optionalErrorMessage }));
}
})
}
}
});
})
}
/**
* This method will ensure that the page gets reset and that the cache is cleared
*/
reset() {
this.groupDataService.getBrowseEndpoint().pipe(take(1)).subscribe((href: string) => {
this.requestService.removeByHrefSubstring(href);
});
this.onCancel();
}
/**
* Cancel the current edit when component is destroyed & unsub all subscriptions
*/
@@ -277,4 +385,58 @@ export class GroupFormComponent implements OnInit, OnDestroy {
this.onCancel();
this.subs.filter((sub) => hasValue(sub)).forEach((sub) => sub.unsubscribe());
}
/**
* Check if group has a linked object (community or collection linked to a workflow group)
* @param group
*/
hasLinkedDSO(group: Group): Observable<boolean> {
if (hasValue(group) && hasValue(group._links.object.href)) {
return this.getLinkedDSO(group).pipe(
map((rd: RemoteData<DSpaceObject>) => {
if (hasValue(rd) && hasValue(rd.payload)) {
return true;
} else {
return false
}
}),
catchError(() => observableOf(false)),
);
}
}
/**
* Get group's linked object if it has one (community or collection linked to a workflow group)
* @param group
*/
getLinkedDSO(group: Group): Observable<RemoteData<DSpaceObject>> {
if (hasValue(group) && hasValue(group._links.object.href)) {
if (group.object === undefined) {
return this.dSpaceObjectDataService.findByHref(group._links.object.href);
}
return group.object;
}
}
/**
* Get the route to the edit roles tab of the group's linked object (community or collection linked to a workflow group) if it has one
* @param group
*/
getLinkedEditRolesRoute(group: Group): Observable<string> {
if (hasValue(group) && hasValue(group._links.object.href)) {
return this.getLinkedDSO(group).pipe(
map((rd: RemoteData<DSpaceObject>) => {
if (hasValue(rd) && hasValue(rd.payload)) {
const dso = rd.payload
switch ((dso as any).type) {
case Community.type.value:
return getCommunityEditRolesRoute(rd.payload.id);
case Collection.type.value:
return getCollectionEditRolesRoute(rd.payload.id);
}
}
})
)
}
}
}

View File

@@ -30,10 +30,10 @@
</form>
<ds-pagination
*ngIf="(groups | async)?.payload?.totalElements > 0"
*ngIf="(pageInfoState$ | async)?.totalElements > 0"
[paginationOptions]="config"
[pageInfoState]="(groups | async)?.payload"
[collectionSize]="(groups | async)?.payload?.totalElements"
[pageInfoState]="pageInfoState$"
[collectionSize]="(pageInfoState$ | async)?.totalElements"
[hideGear]="true"
[hidePagerWhenSinglePage]="true"
(pageChange)="onPageChange($event)">
@@ -50,21 +50,21 @@
</tr>
</thead>
<tbody>
<tr *ngFor="let group of (groups | async)?.payload?.page">
<td>{{group.id}}</td>
<td>{{group.name}}</td>
<td>{{(getMembers(group) | async)?.payload?.totalElements + (getSubgroups(group) | async)?.payload?.totalElements}}</td>
<tr *ngFor="let groupDto of (groupsDto$ | async)?.page">
<td>{{groupDto.group.id}}</td>
<td>{{groupDto.group.name}}</td>
<td>{{(getMembers(groupDto.group) | async)?.payload?.totalElements + (getSubgroups(groupDto.group) | async)?.payload?.totalElements}}</td>
<!-- <td>{{getOptionalComColFromName(group.name)}}</td>-->
<td>
<div class="btn-group edit-field">
<button [routerLink]="groupService.getGroupEditPageRouterLink(group)"
<button [routerLink]="groupService.getGroupEditPageRouterLink(groupDto.group)"
class="btn btn-outline-primary btn-sm"
title="{{messagePrefix + 'table.edit.buttons.edit' | translate: {name: group.name} }}">
title="{{messagePrefix + 'table.edit.buttons.edit' | translate: {name: groupDto.group.name} }}">
<i class="fas fa-edit fa-fw"></i>
</button>
<button *ngIf="!group?.permanent" (click)="deleteGroup(group)"
class="btn btn-outline-danger btn-sm"
title="{{messagePrefix + 'table.edit.buttons.remove' | translate: {name: group.name} }}">
<button *ngIf="!groupDto.group?.permanent && groupDto.ableToDelete"
(click)="deleteGroup(groupDto.group)" class="btn btn-outline-danger btn-sm"
title="{{messagePrefix + 'table.edit.buttons.remove' | translate: {name: groupDto.group.name} }}">
<i class="fas fa-trash-alt fa-fw"></i>
</button>
</div>
@@ -75,7 +75,7 @@
</div>
</ds-pagination>
<div *ngIf="(groups | async)?.payload?.totalElements == 0" class="alert alert-info w-100 mb-2" role="alert">
<div *ngIf="(pageInfoState$ | async)?.totalElements == 0" class="alert alert-info w-100 mb-2" role="alert">
{{messagePrefix + 'no-items' | translate}}
</div>

View File

@@ -6,14 +6,18 @@ import { BrowserModule, By } from '@angular/platform-browser';
import { Router } from '@angular/router';
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
import { Observable } from 'rxjs/internal/Observable';
import { Observable, of as observableOf } from 'rxjs';
import { DSpaceObjectDataService } from '../../../core/data/dspace-object-data.service';
import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service';
import { PaginatedList } from '../../../core/data/paginated-list';
import { RemoteData } from '../../../core/data/remote-data';
import { RequestService } from '../../../core/data/request.service';
import { EPersonDataService } from '../../../core/eperson/eperson-data.service';
import { GroupDataService } from '../../../core/eperson/group-data.service';
import { EPerson } from '../../../core/eperson/models/eperson.model';
import { Group } from '../../../core/eperson/models/group.model';
import { RouteService } from '../../../core/services/route.service';
import { DSpaceObject } from '../../../core/shared/dspace-object.model';
import { PageInfo } from '../../../core/shared/page-info.model';
import { NotificationsService } from '../../../shared/notifications/notifications.service';
import { GroupMock, GroupMock2 } from '../../../shared/testing/group-mock';
@@ -30,6 +34,8 @@ describe('GroupRegistryComponent', () => {
let fixture: ComponentFixture<GroupsRegistryComponent>;
let ePersonDataServiceStub: any;
let groupsDataServiceStub: any;
let dsoDataServiceStub: any;
let authorizationService: AuthorizationDataService;
let mockGroups;
let mockEPeople;
@@ -41,11 +47,11 @@ describe('GroupRegistryComponent', () => {
findAllByHref(href: string): Observable<RemoteData<PaginatedList<EPerson>>> {
switch (href) {
case 'https://dspace.4science.it/dspace-spring-rest/api/eperson/groups/testgroupid2/epersons':
return createSuccessfulRemoteDataObject$(new PaginatedList(null, []));
return createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo({ elementsPerPage: 1, totalElements: 0, totalPages: 0, currentPage: 1 }), []));
case 'https://dspace.4science.it/dspace-spring-rest/api/eperson/groups/testgroupid/epersons':
return createSuccessfulRemoteDataObject$(new PaginatedList(null, [EPersonMock]));
return createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo({ elementsPerPage: 1, totalElements: 1, totalPages: 1, currentPage: 1 }), [EPersonMock]));
default:
return createSuccessfulRemoteDataObject$(new PaginatedList(null, []));
return createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo({ elementsPerPage: 1, totalElements: 0, totalPages: 0, currentPage: 1 }), []));
}
}
};
@@ -54,11 +60,11 @@ describe('GroupRegistryComponent', () => {
findAllByHref(href: string): Observable<RemoteData<PaginatedList<Group>>> {
switch (href) {
case 'https://dspace.4science.it/dspace-spring-rest/api/eperson/groups/testgroupid2/groups':
return createSuccessfulRemoteDataObject$(new PaginatedList(null, []));
return createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo({ elementsPerPage: 1, totalElements: 0, totalPages: 0, currentPage: 1 }), []));
case 'https://dspace.4science.it/dspace-spring-rest/api/eperson/groups/testgroupid/groups':
return createSuccessfulRemoteDataObject$(new PaginatedList(null, [GroupMock2]));
return createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo({ elementsPerPage: 1, totalElements: 1, totalPages: 1, currentPage: 1 }), [GroupMock2]));
default:
return createSuccessfulRemoteDataObject$(new PaginatedList(null, []));
return createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo({ elementsPerPage: 1, totalElements: 0, totalPages: 0, currentPage: 1 }), []));
}
},
getGroupEditPageRouterLink(group: Group): string {
@@ -69,14 +75,22 @@ describe('GroupRegistryComponent', () => {
},
searchGroups(query: string): Observable<RemoteData<PaginatedList<Group>>> {
if (query === '') {
return createSuccessfulRemoteDataObject$(new PaginatedList(null, this.allGroups));
return createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo({ elementsPerPage: this.allGroups.length, totalElements: this.allGroups.length, totalPages: 1, currentPage: 1 }), this.allGroups));
}
const result = this.allGroups.find((group: Group) => {
return (group.id.includes(query))
});
return createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), [result]));
return createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo({ elementsPerPage: [result].length, totalElements: [result].length, totalPages: 1, currentPage: 1 }), [result]));
}
};
dsoDataServiceStub = {
findByHref(href: string): Observable<RemoteData<DSpaceObject>> {
return createSuccessfulRemoteDataObject$(undefined);
}
}
authorizationService = jasmine.createSpyObj('authorizationService', {
isAuthorized: observableOf(true)
});
TestBed.configureTestingModule({
imports: [CommonModule, NgbModule, FormsModule, ReactiveFormsModule, BrowserModule,
TranslateModule.forRoot({
@@ -90,9 +104,12 @@ describe('GroupRegistryComponent', () => {
providers: [GroupsRegistryComponent,
{ provide: EPersonDataService, useValue: ePersonDataServiceStub },
{ provide: GroupDataService, useValue: groupsDataServiceStub },
{ provide: DSpaceObjectDataService, useValue: dsoDataServiceStub },
{ provide: NotificationsService, useValue: new NotificationsServiceStub() },
{ provide: RouteService, useValue: routeServiceStub },
{ provide: Router, useValue: new RouterMock() },
{ provide: AuthorizationDataService, useValue: authorizationService },
{ provide: RequestService, useValue: jasmine.createSpyObj('requestService', ['removeByHrefSubstring'])}
],
schemas: [NO_ERRORS_SCHEMA]
}).compileComponents();

View File

@@ -1,16 +1,26 @@
import { Component, OnInit } from '@angular/core';
import { Component, OnDestroy, OnInit } from '@angular/core';
import { FormBuilder } from '@angular/forms';
import { Router } from '@angular/router';
import { TranslateService } from '@ngx-translate/core';
import { Observable } from 'rxjs';
import { take } from 'rxjs/operators';
import { BehaviorSubject, combineLatest as observableCombineLatest, Subscription, Observable, of as observableOf } from 'rxjs';
import { filter } from 'rxjs/internal/operators/filter';
import { ObservedValueOf } from 'rxjs/internal/types';
import { catchError, map, switchMap, take } from 'rxjs/operators';
import { DSpaceObjectDataService } from '../../../core/data/dspace-object-data.service';
import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service';
import { FeatureID } from '../../../core/data/feature-authorization/feature-id';
import { PaginatedList } from '../../../core/data/paginated-list';
import { RemoteData } from '../../../core/data/remote-data';
import { RequestService } from '../../../core/data/request.service';
import { EPersonDataService } from '../../../core/eperson/eperson-data.service';
import { GroupDataService } from '../../../core/eperson/group-data.service';
import { EPerson } from '../../../core/eperson/models/eperson.model';
import { GroupDtoModel } from '../../../core/eperson/models/group-dto.model';
import { Group } from '../../../core/eperson/models/group.model';
import { RouteService } from '../../../core/services/route.service';
import { DSpaceObject } from '../../../core/shared/dspace-object.model';
import { getAllSucceededRemoteDataPayload } from '../../../core/shared/operators';
import { PageInfo } from '../../../core/shared/page-info.model';
import { hasValue } from '../../../shared/empty.util';
import { NotificationsService } from '../../../shared/notifications/notifications.service';
import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model';
@@ -23,7 +33,7 @@ import { PaginationComponentOptions } from '../../../shared/pagination/paginatio
* A component used for managing all existing groups within the repository.
* The admin can create, edit or delete groups here.
*/
export class GroupsRegistryComponent implements OnInit {
export class GroupsRegistryComponent implements OnInit, OnDestroy {
messagePrefix = 'admin.access-control.groups.';
@@ -37,9 +47,19 @@ export class GroupsRegistryComponent implements OnInit {
});
/**
* A list of all the current groups within the repository or the result of the search
* A list of all the current Groups within the repository or the result of the search
*/
groups: Observable<RemoteData<PaginatedList<Group>>>;
groups$: BehaviorSubject<RemoteData<PaginatedList<Group>>> = new BehaviorSubject<RemoteData<PaginatedList<Group>>>({} as any);
/**
* A BehaviorSubject with the list of GroupDtoModel objects made from the Groups in the repository or
* as the result of the search
*/
groupsDto$: BehaviorSubject<PaginatedList<GroupDtoModel>> = new BehaviorSubject<PaginatedList<GroupDtoModel>>({} as any);
/**
* An observable for the pageInfo, needed to pass to the pagination component
*/
pageInfoState$: BehaviorSubject<PageInfo> = new BehaviorSubject<PageInfo>(undefined);
// The search form
searchForm;
@@ -47,13 +67,21 @@ export class GroupsRegistryComponent implements OnInit {
// Current search in groups registry
currentSearchQuery: string;
/**
* List of subscriptions
*/
subs: Subscription[] = [];
constructor(public groupService: GroupDataService,
private ePersonDataService: EPersonDataService,
private dSpaceObjectDataService: DSpaceObjectDataService,
private translateService: TranslateService,
private notificationsService: NotificationsService,
private formBuilder: FormBuilder,
protected routeService: RouteService,
private router: Router) {
private router: Router,
private authorizationService: AuthorizationDataService,
public requestService: RequestService) {
this.currentSearchQuery = '';
this.searchForm = this.formBuilder.group(({
query: this.currentSearchQuery,
@@ -84,37 +112,69 @@ export class GroupsRegistryComponent implements OnInit {
this.currentSearchQuery = query;
this.config.currentPage = 1;
}
this.groups = this.groupService.searchGroups(this.currentSearchQuery.trim(), {
this.subs.push(this.groupService.searchGroups(this.currentSearchQuery.trim(), {
currentPage: this.config.currentPage,
elementsPerPage: this.config.pageSize
});
}).subscribe((groupsRD: RemoteData<PaginatedList<Group>>) => {
this.groups$.next(groupsRD);
this.pageInfoState$.next(groupsRD.payload.pageInfo);
}
));
this.subs.push(this.groups$.pipe(
getAllSucceededRemoteDataPayload(),
switchMap((groups: PaginatedList<Group>) => {
return observableCombineLatest(...groups.page.map((group: Group) => {
return observableCombineLatest(
this.authorizationService.isAuthorized(FeatureID.CanDelete, hasValue(group) ? group.self : undefined),
this.hasLinkedDSO(group),
(isAuthorized: ObservedValueOf<Observable<boolean>>, hasLinkedDSO: ObservedValueOf<Observable<boolean>>) => {
const groupDtoModel: GroupDtoModel = new GroupDtoModel();
groupDtoModel.ableToDelete = isAuthorized && !hasLinkedDSO;
groupDtoModel.group = group;
return groupDtoModel;
}
)
})).pipe(map((dtos: GroupDtoModel[]) => {
return new PaginatedList(groups.pageInfo, dtos);
}))
})).subscribe((value: PaginatedList<GroupDtoModel>) => {
this.groupsDto$.next(value);
this.pageInfoState$.next(value.pageInfo);
}));
}
/**
* Delete Group
*/
deleteGroup(group: Group) {
// TODO (backend)
console.log('TODO implement editGroup', group);
this.notificationsService.error('TODO implement deleteGroup (not yet implemented in backend)');
if (hasValue(group.id)) {
this.groupService.deleteGroup(group).pipe(take(1)).subscribe((success: boolean) => {
if (success) {
this.notificationsService.success(this.translateService.get(this.messagePrefix + 'notification.deleted.success', { name: group.name }));
this.forceUpdateGroup();
} else {
this.notificationsService.error(this.translateService.get(this.messagePrefix + 'notification.deleted.failure', { name: group.name }));
}
})
this.groupService.deleteGroup(group).pipe(take(1))
.subscribe(([success, optionalErrorMessage]: [boolean, string]) => {
if (success) {
this.notificationsService.success(this.translateService.get(this.messagePrefix + 'notification.deleted.success', { name: group.name }));
this.reset();
} else {
this.notificationsService.error(
this.translateService.get(this.messagePrefix + 'notification.deleted.failure.title', { name: group.name }),
this.translateService.get(this.messagePrefix + 'notification.deleted.failure.content', { cause: optionalErrorMessage }));
}
})
}
}
/**
* Force-update the list of groups by first clearing the cache related to groups, then performing a new REST call
* This method will ensure that the page gets reset and that the cache is cleared
*/
public forceUpdateGroup() {
this.groupService.clearGroupsRequests();
this.search({ query: this.currentSearchQuery })
reset() {
this.groupService.getBrowseEndpoint().pipe(
switchMap((href) => this.requestService.removeByHrefSubstring(href)),
filter((isCached) => isCached),
take(1)
).subscribe(() => {
this.cleanupSubscribes();
this.search({ query: this.currentSearchQuery });
});
}
/**
@@ -133,6 +193,23 @@ export class GroupsRegistryComponent implements OnInit {
return this.groupService.findAllByHref(group._links.subgroups.href);
}
/**
* Check if group has a linked object (community or collection linked to a workflow group)
* @param group
*/
hasLinkedDSO(group: Group): Observable<boolean> {
return this.dSpaceObjectDataService.findByHref(group._links.object.href).pipe(
map((rd: RemoteData<DSpaceObject>) => {
if (hasValue(rd) && hasValue(rd.payload)) {
return true;
} else {
return false
}
}),
catchError(() => observableOf(false)),
);
}
/**
* Reset all input-fields to be empty and search all search
*/
@@ -151,4 +228,15 @@ export class GroupsRegistryComponent implements OnInit {
getOptionalComColFromName(groupName: string): string {
return this.groupService.getUUIDFromString(groupName);
}
/**
* Unsub all subscriptions
*/
ngOnDestroy(): void {
this.cleanupSubscribes();
}
cleanupSubscribes() {
this.subs.filter((sub) => hasValue(sub)).forEach((sub) => sub.unsubscribe());
}
}

View File

@@ -7,6 +7,7 @@ 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';
import { AuthService } from '../core/auth/auth.service';
@Injectable({
providedIn: 'root'
@@ -17,8 +18,9 @@ import { FeatureID } from '../core/data/feature-authorization/feature-id';
export class CollectionPageAdministratorGuard extends DsoPageFeatureGuard<Collection> {
constructor(protected resolver: CollectionPageResolver,
protected authorizationService: AuthorizationDataService,
protected router: Router) {
super(resolver, authorizationService, router);
protected router: Router,
protected authService: AuthService) {
super(resolver, authorizationService, router, authService);
}
/**

View File

@@ -20,6 +20,11 @@ export function getCollectionCreateRoute() {
return new URLCombiner(getCollectionModuleRoute(), COLLECTION_CREATE_PATH).toString()
}
export function getCollectionEditRolesRoute(id) {
return new URLCombiner(getCollectionPageRoute(id), COLLECTION_EDIT_PATH, COLLECTION_EDIT_ROLES_PATH).toString()
}
export const COLLECTION_CREATE_PATH = 'create';
export const COLLECTION_EDIT_PATH = 'edit';
export const COLLECTION_EDIT_ROLES_PATH = 'roles';
export const ITEMTEMPLATE_PATH = 'itemtemplate';

View File

@@ -17,13 +17,14 @@ import { DSpaceObjectType } from '../core/shared/dspace-object-type.model';
import { Item } from '../core/shared/item.model';
import {
getSucceededRemoteData,
redirectOn404Or401,
redirectOn4xx,
toDSpaceObjectListRD
} from '../core/shared/operators';
import { fadeIn, fadeInOut } from '../shared/animations/fade';
import { hasNoValue, hasValue, isNotEmpty } from '../shared/empty.util';
import { PaginationComponentOptions } from '../shared/pagination/pagination-component-options.model';
import { AuthService } from '../core/auth/auth.service';
@Component({
selector: 'ds-collection-page',
@@ -51,7 +52,8 @@ export class CollectionPageComponent implements OnInit {
private searchService: SearchService,
private metadata: MetadataService,
private route: ActivatedRoute,
private router: Router
private router: Router,
private authService: AuthService,
) {
this.paginationConfig = new PaginationComponentOptions();
this.paginationConfig.id = 'collection-page-pagination';
@@ -63,7 +65,7 @@ export class CollectionPageComponent implements OnInit {
ngOnInit(): void {
this.collectionRD$ = this.route.data.pipe(
map((data) => data.dso as RemoteData<Collection>),
redirectOn404Or401(this.router),
redirectOn4xx(this.router, this.authService),
take(1)
);
this.logoRD$ = this.collectionRD$.pipe(

View File

@@ -7,6 +7,7 @@ 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';
import { AuthService } from '../core/auth/auth.service';
@Injectable({
providedIn: 'root'
@@ -17,8 +18,9 @@ import { FeatureID } from '../core/data/feature-authorization/feature-id';
export class CommunityPageAdministratorGuard extends DsoPageFeatureGuard<Community> {
constructor(protected resolver: CommunityPageResolver,
protected authorizationService: AuthorizationDataService,
protected router: Router) {
super(resolver, authorizationService, router);
protected router: Router,
protected authService: AuthService) {
super(resolver, authorizationService, router, authService);
}
/**

View File

@@ -1,3 +1,4 @@
import { getCollectionPageRoute } from '../+collection-page/collection-page-routing-paths';
import { URLCombiner } from '../core/url-combiner/url-combiner';
export const COMMUNITY_PARENT_PARAMETER = 'parent';
@@ -20,5 +21,10 @@ export function getCommunityCreateRoute() {
return new URLCombiner(getCommunityModuleRoute(), COMMUNITY_CREATE_PATH).toString()
}
export function getCommunityEditRolesRoute(id) {
return new URLCombiner(getCollectionPageRoute(id), COMMUNITY_EDIT_PATH, COMMUNITY_EDIT_ROLES_PATH).toString()
}
export const COMMUNITY_CREATE_PATH = 'create';
export const COMMUNITY_EDIT_PATH = 'edit';
export const COMMUNITY_EDIT_ROLES_PATH = 'roles';

View File

@@ -13,7 +13,8 @@ import { MetadataService } from '../core/metadata/metadata.service';
import { fadeInOut } from '../shared/animations/fade';
import { hasValue } from '../shared/empty.util';
import { redirectOn404Or401 } from '../core/shared/operators';
import { redirectOn4xx } from '../core/shared/operators';
import { AuthService } from '../core/auth/auth.service';
@Component({
selector: 'ds-community-page',
@@ -39,7 +40,8 @@ export class CommunityPageComponent implements OnInit {
private communityDataService: CommunityDataService,
private metadata: MetadataService,
private route: ActivatedRoute,
private router: Router
private router: Router,
private authService: AuthService,
) {
}
@@ -47,7 +49,7 @@ export class CommunityPageComponent implements OnInit {
ngOnInit(): void {
this.communityRD$ = this.route.data.pipe(
map((data) => data.dso as RemoteData<Community>),
redirectOn404Or401(this.router)
redirectOn4xx(this.router, this.authService)
);
this.logoRD$ = this.communityRD$.pipe(
map((rd: RemoteData<Community>) => rd.payload),

View File

@@ -7,6 +7,7 @@ import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/ro
import { Observable } from 'rxjs/internal/Observable';
import { FeatureID } from '../../core/data/feature-authorization/feature-id';
import { of as observableOf } from 'rxjs';
import { AuthService } from '../../core/auth/auth.service';
@Injectable({
providedIn: 'root'
@@ -17,8 +18,9 @@ import { of as observableOf } from 'rxjs';
export class ItemPageReinstateGuard extends DsoPageFeatureGuard<Item> {
constructor(protected resolver: ItemPageResolver,
protected authorizationService: AuthorizationDataService,
protected router: Router) {
super(resolver, authorizationService, router);
protected router: Router,
protected authService: AuthService) {
super(resolver, authorizationService, router, authService);
}
/**

View File

@@ -7,6 +7,7 @@ import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/ro
import { Observable } from 'rxjs/internal/Observable';
import { FeatureID } from '../../core/data/feature-authorization/feature-id';
import { of as observableOf } from 'rxjs';
import { AuthService } from '../../core/auth/auth.service';
@Injectable({
providedIn: 'root'
@@ -17,8 +18,9 @@ import { of as observableOf } from 'rxjs';
export class ItemPageWithdrawGuard extends DsoPageFeatureGuard<Item> {
constructor(protected resolver: ItemPageResolver,
protected authorizationService: AuthorizationDataService,
protected router: Router) {
super(resolver, authorizationService, router);
protected router: Router,
protected authService: AuthService) {
super(resolver, authorizationService, router, authService);
}
/**

View File

@@ -20,6 +20,7 @@ import {
createSuccessfulRemoteDataObject,
createSuccessfulRemoteDataObject$
} from '../../shared/remote-data.utils';
import { AuthService } from '../../core/auth/auth.service';
const mockItem: Item = Object.assign(new Item(), {
bundles: createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), [])),
@@ -45,7 +46,14 @@ describe('FullItemPageComponent', () => {
let comp: FullItemPageComponent;
let fixture: ComponentFixture<FullItemPageComponent>;
let authService: AuthService;
beforeEach(async(() => {
authService = jasmine.createSpyObj('authService', {
isAuthenticated: observableOf(true),
setRedirectUrl: {}
});
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot({
loader: {
@@ -57,7 +65,8 @@ describe('FullItemPageComponent', () => {
providers: [
{provide: ActivatedRoute, useValue: routeStub},
{provide: ItemDataService, useValue: {}},
{provide: MetadataService, useValue: metadataServiceStub}
{provide: MetadataService, useValue: metadataServiceStub},
{ provide: AuthService, useValue: authService },
],
schemas: [NO_ERRORS_SCHEMA]

View File

@@ -15,6 +15,7 @@ import { MetadataService } from '../../core/metadata/metadata.service';
import { fadeInOut } from '../../shared/animations/fade';
import { hasValue } from '../../shared/empty.util';
import { AuthService } from '../../core/auth/auth.service';
/**
* This component renders a simple item page.
@@ -35,8 +36,8 @@ export class FullItemPageComponent extends ItemPageComponent implements OnInit {
metadata$: Observable<MetadataMap>;
constructor(route: ActivatedRoute, router: Router, items: ItemDataService, metadataService: MetadataService) {
super(route, router, items, metadataService);
constructor(route: ActivatedRoute, router: Router, items: ItemDataService, metadataService: MetadataService, authService: AuthService) {
super(route, router, items, metadataService, authService);
}
/*** AoT inheritance fix, will hopefully be resolved in the near future **/

View File

@@ -7,6 +7,7 @@ import { DsoPageFeatureGuard } from '../core/data/feature-authorization/feature-
import { Observable } from 'rxjs/internal/Observable';
import { FeatureID } from '../core/data/feature-authorization/feature-id';
import { of as observableOf } from 'rxjs';
import { AuthService } from '../core/auth/auth.service';
@Injectable({
providedIn: 'root'
@@ -17,8 +18,9 @@ import { of as observableOf } from 'rxjs';
export class ItemPageAdministratorGuard extends DsoPageFeatureGuard<Item> {
constructor(protected resolver: ItemPageResolver,
protected authorizationService: AuthorizationDataService,
protected router: Router) {
super(resolver, authorizationService, router);
protected router: Router,
protected authService: AuthService) {
super(resolver, authorizationService, router, authService);
}
/**

View File

@@ -19,6 +19,7 @@ import {
createFailedRemoteDataObject$, createPendingRemoteDataObject$, createSuccessfulRemoteDataObject,
createSuccessfulRemoteDataObject$
} from '../../shared/remote-data.utils';
import { AuthService } from '../../core/auth/auth.service';
const mockItem: Item = Object.assign(new Item(), {
bundles: createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), [])),
@@ -29,6 +30,7 @@ const mockItem: Item = Object.assign(new Item(), {
describe('ItemPageComponent', () => {
let comp: ItemPageComponent;
let fixture: ComponentFixture<ItemPageComponent>;
let authService: AuthService;
const mockMetadataService = {
/* tslint:disable:no-empty */
@@ -40,6 +42,11 @@ describe('ItemPageComponent', () => {
});
beforeEach(async(() => {
authService = jasmine.createSpyObj('authService', {
isAuthenticated: observableOf(true),
setRedirectUrl: {}
});
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot({
loader: {
@@ -52,7 +59,8 @@ describe('ItemPageComponent', () => {
{provide: ActivatedRoute, useValue: mockRoute},
{provide: ItemDataService, useValue: {}},
{provide: MetadataService, useValue: mockMetadataService},
{provide: Router, useValue: {}}
{provide: Router, useValue: {}},
{ provide: AuthService, useValue: authService },
],
schemas: [NO_ERRORS_SCHEMA]

View File

@@ -11,8 +11,9 @@ import { Item } from '../../core/shared/item.model';
import { MetadataService } from '../../core/metadata/metadata.service';
import { fadeInOut } from '../../shared/animations/fade';
import { redirectOn404Or401 } from '../../core/shared/operators';
import { redirectOn4xx } from '../../core/shared/operators';
import { ViewMode } from '../../core/shared/view-mode.model';
import { AuthService } from '../../core/auth/auth.service';
/**
* This component renders a simple item page.
@@ -48,6 +49,7 @@ export class ItemPageComponent implements OnInit {
private router: Router,
private items: ItemDataService,
private metadataService: MetadataService,
private authService: AuthService,
) { }
/**
@@ -56,7 +58,7 @@ export class ItemPageComponent implements OnInit {
ngOnInit(): void {
this.itemRD$ = this.route.data.pipe(
map((data) => data.dso as RemoteData<Item>),
redirectOn404Or401(this.router)
redirectOn4xx(this.router, this.authService)
);
this.metadataService.processRemoteData(this.itemRD$);
}

View File

@@ -55,10 +55,10 @@ export function getDSORoute(dso: DSpaceObject): string {
}
}
export const UNAUTHORIZED_PATH = '401';
export const FORBIDDEN_PATH = '403';
export function getUnauthorizedRoute() {
return `/${UNAUTHORIZED_PATH}`;
export function getForbiddenRoute() {
return `/${FORBIDDEN_PATH}`;
}
export const PAGE_NOT_FOUND_PATH = '404';

View File

@@ -5,16 +5,15 @@ import { AuthBlockingGuard } from './core/auth/auth-blocking.guard';
import { PageNotFoundComponent } from './pagenotfound/pagenotfound.component';
import { AuthenticatedGuard } from './core/auth/authenticated.guard';
import { SiteAdministratorGuard } from './core/data/feature-authorization/feature-authorization-guard/site-administrator.guard';
import { UnauthorizedComponent } from './unauthorized/unauthorized.component';
import {
UNAUTHORIZED_PATH,
WORKFLOW_ITEM_MODULE_PATH,
FORGOT_PASSWORD_PATH,
REGISTER_PATH,
PROFILE_MODULE_PATH,
ADMIN_MODULE_PATH,
BITSTREAM_MODULE_PATH,
INFO_MODULE_PATH
INFO_MODULE_PATH,
FORBIDDEN_PATH,
} from './app-routing-paths';
import { COLLECTION_MODULE_PATH } from './+collection-page/collection-page-routing-paths';
import { COMMUNITY_MODULE_PATH } from './+community-page/community-page-routing-paths';
@@ -22,6 +21,7 @@ import { ITEM_MODULE_PATH } from './+item-page/item-page-routing-paths';
import { ReloadGuard } from './core/reload/reload.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';
import { ForbiddenComponent } from './forbidden/forbidden.component';
@NgModule({
imports: [
@@ -68,7 +68,7 @@ import { SiteRegisterGuard } from './core/data/feature-authorization/feature-aut
},
{ path: 'processes', loadChildren: './process-page/process-page.module#ProcessPageModule', canActivate: [AuthenticatedGuard, EndUserAgreementCurrentUserGuard] },
{ path: INFO_MODULE_PATH, loadChildren: './info/info.module#InfoModule' },
{ path: UNAUTHORIZED_PATH, component: UnauthorizedComponent },
{ path: FORBIDDEN_PATH, component: ForbiddenComponent },
{
path: 'statistics',
loadChildren: './statistics-page/statistics-page-routing.module#StatisticsPageRoutingModule',

View File

@@ -41,7 +41,7 @@ import { SharedModule } from './shared/shared.module';
import { BreadcrumbsComponent } from './breadcrumbs/breadcrumbs.component';
import { environment } from '../environments/environment';
import { BrowserModule } from '@angular/platform-browser';
import { UnauthorizedComponent } from './unauthorized/unauthorized.component';
import { ForbiddenComponent } from './forbidden/forbidden.component';
export function getBase() {
return environment.ui.nameSpace;
@@ -116,6 +116,8 @@ const DECLARATIONS = [
NotificationComponent,
NotificationsBoardComponent,
SearchNavbarComponent,
BreadcrumbsComponent,
ForbiddenComponent,
];
const EXPORTS = [
@@ -133,8 +135,6 @@ const EXPORTS = [
],
declarations: [
...DECLARATIONS,
BreadcrumbsComponent,
UnauthorizedComponent,
],
exports: [
...EXPORTS

View File

@@ -453,7 +453,7 @@ export class AuthService {
* Clear redirect url
*/
clearRedirectUrl() {
this.store.dispatch(new SetRedirectUrlAction(''));
this.store.dispatch(new SetRedirectUrlAction(undefined));
this.storage.remove(REDIRECT_COOKIE);
}

View File

@@ -58,4 +58,8 @@ export class DSpaceObjectDataService {
findById(uuid: string): Observable<RemoteData<DSpaceObject>> {
return this.dataService.findById(uuid);
}
findByHref(href: string): Observable<RemoteData<DSpaceObject>> {
return this.dataService.findByHref(href);
}
}

View File

@@ -20,7 +20,7 @@ import { followLink, FollowLinkConfig } from '../../../shared/utils/follow-link-
import { Observable } from 'rxjs/internal/Observable';
import { RemoteData } from '../remote-data';
import { PaginatedList } from '../paginated-list';
import { find, map, switchMap, tap } from 'rxjs/operators';
import { catchError, find, map, switchMap, tap } from 'rxjs/operators';
import { hasValue, isNotEmpty } from '../../../shared/empty.util';
import { RequestParam } from '../../cache/models/request-param.model';
import { AuthorizationSearchParams } from './authorization-search-params';
@@ -71,6 +71,7 @@ export class AuthorizationDataService extends DataService<Authorization> {
return [];
}
}),
catchError(() => observableOf(false)),
oneAuthorizationMatchesFeature(featureId)
);
}

View File

@@ -7,6 +7,7 @@ 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';
import { AuthService } from '../../../auth/auth.service';
/**
* Test implementation of abstract class DsoPageAdministratorGuard
@@ -15,8 +16,9 @@ class DsoPageFeatureGuardImpl extends DsoPageFeatureGuard<any> {
constructor(protected resolver: Resolve<RemoteData<any>>,
protected authorizationService: AuthorizationDataService,
protected router: Router,
protected authService: AuthService,
protected featureID: FeatureID) {
super(resolver, authorizationService, router);
super(resolver, authorizationService, router, authService);
}
getFeatureID(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<FeatureID> {
@@ -28,6 +30,7 @@ describe('DsoPageAdministratorGuard', () => {
let guard: DsoPageFeatureGuard<any>;
let authorizationService: AuthorizationDataService;
let router: Router;
let authService: AuthService;
let resolver: Resolve<RemoteData<any>>;
let object: DSpaceObject;
@@ -45,7 +48,10 @@ describe('DsoPageAdministratorGuard', () => {
resolver = jasmine.createSpyObj('resolver', {
resolve: createSuccessfulRemoteDataObject$(object)
});
guard = new DsoPageFeatureGuardImpl(resolver, authorizationService, router, undefined);
authService = jasmine.createSpyObj('authService', {
isAuthenticated: observableOf(true)
});
guard = new DsoPageFeatureGuardImpl(resolver, authorizationService, router, authService, undefined);
}
beforeEach(() => {

View File

@@ -6,6 +6,7 @@ import { getAllSucceededRemoteDataPayload } from '../../../shared/operators';
import { map } from 'rxjs/operators';
import { DSpaceObject } from '../../../shared/dspace-object.model';
import { FeatureAuthorizationGuard } from './feature-authorization.guard';
import { AuthService } from '../../../auth/auth.service';
/**
* Abstract Guard for preventing unauthorized access to {@link DSpaceObject} pages that require rights for a specific feature
@@ -14,8 +15,9 @@ import { FeatureAuthorizationGuard } from './feature-authorization.guard';
export abstract class DsoPageFeatureGuard<T extends DSpaceObject> extends FeatureAuthorizationGuard {
constructor(protected resolver: Resolve<RemoteData<T>>,
protected authorizationService: AuthorizationDataService,
protected router: Router) {
super(authorizationService, router);
protected router: Router,
protected authService: AuthService) {
super(authorizationService, router, authService);
}
/**

View File

@@ -4,6 +4,7 @@ import { FeatureID } from '../feature-id';
import { of as observableOf } from 'rxjs';
import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router';
import { Observable } from 'rxjs/internal/Observable';
import { AuthService } from '../../../auth/auth.service';
/**
* Test implementation of abstract class FeatureAuthorizationGuard
@@ -12,10 +13,11 @@ import { Observable } from 'rxjs/internal/Observable';
class FeatureAuthorizationGuardImpl extends FeatureAuthorizationGuard {
constructor(protected authorizationService: AuthorizationDataService,
protected router: Router,
protected authService: AuthService,
protected featureId: FeatureID,
protected objectUrl: string,
protected ePersonUuid: string) {
super(authorizationService, router);
super(authorizationService, router, authService);
}
getFeatureID(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<FeatureID> {
@@ -35,6 +37,7 @@ describe('FeatureAuthorizationGuard', () => {
let guard: FeatureAuthorizationGuard;
let authorizationService: AuthorizationDataService;
let router: Router;
let authService: AuthService;
let featureId: FeatureID;
let objectUrl: string;
@@ -51,7 +54,10 @@ describe('FeatureAuthorizationGuard', () => {
router = jasmine.createSpyObj('router', {
parseUrl: {}
});
guard = new FeatureAuthorizationGuardImpl(authorizationService, router, featureId, objectUrl, ePersonUuid);
authService = jasmine.createSpyObj('authService', {
isAuthenticated: observableOf(true)
});
guard = new FeatureAuthorizationGuardImpl(authorizationService, router, authService, featureId, objectUrl, ePersonUuid);
}
beforeEach(() => {
@@ -60,7 +66,7 @@ describe('FeatureAuthorizationGuard', () => {
describe('canActivate', () => {
it('should call authorizationService.isAuthenticated with the appropriate arguments', () => {
guard.canActivate(undefined, undefined).subscribe();
guard.canActivate(undefined, { url: 'current-url' } as any).subscribe();
expect(authorizationService.isAuthorized).toHaveBeenCalledWith(featureId, objectUrl, ePersonUuid);
});
});

View File

@@ -8,9 +8,10 @@ import {
import { AuthorizationDataService } from '../authorization-data.service';
import { FeatureID } from '../feature-id';
import { Observable } from 'rxjs/internal/Observable';
import { returnUnauthorizedUrlTreeOnFalse } from '../../../shared/operators';
import { returnForbiddenUrlTreeOrLoginOnFalse } from '../../../shared/operators';
import { combineLatest as observableCombineLatest, of as observableOf } from 'rxjs';
import { switchMap } from 'rxjs/operators';
import { AuthService } from '../../../auth/auth.service';
/**
* Abstract Guard for preventing unauthorized activating and loading of routes when a user
@@ -19,7 +20,8 @@ import { switchMap } from 'rxjs/operators';
*/
export abstract class FeatureAuthorizationGuard implements CanActivate {
constructor(protected authorizationService: AuthorizationDataService,
protected router: Router) {
protected router: Router,
protected authService: AuthService) {
}
/**
@@ -29,7 +31,7 @@ export abstract class FeatureAuthorizationGuard implements CanActivate {
canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<boolean | UrlTree> {
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)
returnForbiddenUrlTreeOrLoginOnFalse(this.router, this.authService, state.url)
);
}

View File

@@ -5,6 +5,7 @@ import { AuthorizationDataService } from '../authorization-data.service';
import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router';
import { of as observableOf } from 'rxjs';
import { Observable } from 'rxjs/internal/Observable';
import { AuthService } from '../../../auth/auth.service';
/**
* Prevent unauthorized activating and loading of routes when the current authenticated user doesn't have administrator
@@ -14,8 +15,8 @@ import { Observable } from 'rxjs/internal/Observable';
providedIn: 'root'
})
export class SiteAdministratorGuard extends FeatureAuthorizationGuard {
constructor(protected authorizationService: AuthorizationDataService, protected router: Router) {
super(authorizationService, router);
constructor(protected authorizationService: AuthorizationDataService, protected router: Router, protected authService: AuthService) {
super(authorizationService, router, authService);
}
/**

View File

@@ -5,6 +5,7 @@ import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/ro
import { Observable } from 'rxjs/internal/Observable';
import { FeatureID } from '../feature-id';
import { of as observableOf } from 'rxjs';
import { AuthService } from '../../../auth/auth.service';
/**
* Prevent unauthorized activating and loading of routes when the current authenticated user doesn't have registration
@@ -14,8 +15,8 @@ import { of as observableOf } from 'rxjs';
providedIn: 'root'
})
export class SiteRegisterGuard extends FeatureAuthorizationGuard {
constructor(protected authorizationService: AuthorizationDataService, protected router: Router) {
super(authorizationService, router);
constructor(protected authorizationService: AuthorizationDataService, protected router: Router, protected authService: AuthService) {
super(authorizationService, router, authService);
}
/**

View File

@@ -2,6 +2,7 @@ import { HttpClient, HttpHeaders } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { createSelector, select, Store } from '@ngrx/store';
import { Operation } from 'fast-json-patch/lib/core';
import { Observable } from 'rxjs';
import { filter, map, take, tap } from 'rxjs/operators';
import {
@@ -16,17 +17,24 @@ import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
import { RequestParam } from '../cache/models/request-param.model';
import { ObjectCacheService } from '../cache/object-cache.service';
import { RestResponse } from '../cache/response.models';
import { ErrorResponse, RestResponse } from '../cache/response.models';
import { DataService } from '../data/data.service';
import { DSOChangeAnalyzer } from '../data/dso-change-analyzer.service';
import { PaginatedList } from '../data/paginated-list';
import { RemoteData } from '../data/remote-data';
import { CreateRequest, DeleteRequest, FindListOptions, FindListRequest, PostRequest } from '../data/request.models';
import {
CreateRequest,
DeleteRequest,
FindListOptions,
FindListRequest,
PatchRequest,
PostRequest
} from '../data/request.models';
import { RequestService } from '../data/request.service';
import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service';
import { HALEndpointService } from '../shared/hal-endpoint.service';
import { getResponseFromEntry } from '../shared/operators';
import { getRemoteDataPayload, getResponseFromEntry, getSucceededRemoteData } from '../shared/operators';
import { EPerson } from './models/eperson.model';
import { Group } from './models/group.model';
import { dataService } from '../cache/builders/build-decorators';
@@ -125,33 +133,51 @@ export class GroupDataService extends DataService<Group> {
/**
* Method to delete a group
* @param id The group id to delete
* @param group The group to delete
*/
public deleteGroup(group: Group): Observable<boolean> {
return this.delete(group.id).pipe(map((response: RestResponse) => response.isSuccessful));
public deleteGroup(group: Group): Observable<[boolean, string]> {
return this.delete(group.id).pipe(map((response: RestResponse) => {
const errorMessage = response.isSuccessful === false ? (response as ErrorResponse).errorMessage : undefined;
return [response.isSuccessful, errorMessage];
}));
}
/**
* Create or Update a group
* If the group contains an id, it is assumed the eperson already exists and is updated instead
* @param group The group to create or update
* Add a new patch to the object cache
* The patch is derived from the differences between the given object and its version in the object cache
* @param group The group with changes
*/
public createOrUpdateGroup(group: Group): Observable<RemoteData<Group>> {
const isUpdate = hasValue(group.id);
if (isUpdate) {
return this.updateGroup(group);
} else {
return this.create(group, null);
updateGroup(group: Group): Observable<RestResponse> {
const requestId = this.requestService.generateRequestId();
const oldVersion$ = this.findByHref(group._links.self.href);
oldVersion$.pipe(
getSucceededRemoteData(),
getRemoteDataPayload(),
map((oldGroup: Group) => {
const operations = this.generateOperations(oldGroup, group);
const patchRequest = new PatchRequest(requestId, group._links.self.href, operations);
return this.requestService.configure(patchRequest);
}),
take(1)
).subscribe();
return this.fetchResponse(requestId);
}
/**
* Metadata operations are generated by the difference between old and new Group
* Custom replace operation for the other group Name value
* @param oldGroup
* @param newGroup
*/
private generateOperations(oldGroup: Group, newGroup: Group): Operation[] {
let operations = this.comparator.diff(oldGroup, newGroup).filter((operation: Operation) => operation.op === 'replace');
if (hasValue(oldGroup.name) && oldGroup.name !== newGroup.name) {
operations = [...operations, {
op: 'replace', path: '/name', value: newGroup.name
}];
}
}
/**
* // TODO
* @param {DSpaceObject} ePerson The given object
*/
updateGroup(group: Group): Observable<RemoteData<Group>> {
// TODO
return null;
return operations;
}
/**

View File

@@ -0,0 +1,17 @@
import { Group } from './group.model';
/**
* This class serves as a Data Transfer Model that contains the Group and whether or not it's able to be deleted
*/
export class GroupDtoModel {
/**
* The Group linked to this object
*/
public group: Group;
/**
* Whether or not the linked Group is able to be deleted
*/
public ableToDelete: boolean;
}

View File

@@ -5,6 +5,7 @@ import { PaginatedList } from '../../data/paginated-list';
import { RemoteData } from '../../data/remote-data';
import { DSpaceObject } from '../../shared/dspace-object.model';
import { DSPACE_OBJECT } from '../../shared/dspace-object.resource-type';
import { HALLink } from '../../shared/hal-link.model';
import { EPerson } from './eperson.model';
import { EPERSON } from './eperson.resource-type';
@@ -41,6 +42,7 @@ export class Group extends DSpaceObject {
self: HALLink;
subgroups: HALLink;
epersons: HALLink;
object: HALLink;
};
/**
@@ -57,4 +59,11 @@ export class Group extends DSpaceObject {
@link(EPERSON, true)
public epersons?: Observable<RemoteData<PaginatedList<EPerson>>>;
/**
* Connected dspace object, the community or collection connected to a workflow group (204 no content for non-workflow groups)
* Will be undefined unless the object {@link HALLink} has been resolved (can only be resolved for workflow groups)
*/
@link(DSPACE_OBJECT)
public object?: Observable<RemoteData<DSpaceObject>>;
}

View File

@@ -24,6 +24,10 @@ export class ServerResponseService {
return this.setStatus(401, message)
}
setForbidden(message = 'Forbidden'): this {
return this.setStatus(403, message)
}
setNotFound(message = 'Not found'): this {
return this.setStatus(404, message)
}

View File

@@ -14,7 +14,7 @@ import {
getResourceLinksFromResponse,
getResponseFromEntry,
getSucceededRemoteData,
redirectOn404Or401
redirectOn4xx
} from './operators';
import { RemoteData } from '../data/remote-data';
import { RemoteDataError } from '../data/remote-data-error';
@@ -200,39 +200,67 @@ describe('Core Module - RxJS Operators', () => {
});
});
describe('redirectOn404Or401', () => {
describe('redirectOn4xx', () => {
let router;
let authService;
beforeEach(() => {
router = jasmine.createSpyObj('router', ['navigateByUrl']);
authService = jasmine.createSpyObj('authService', {
isAuthenticated: observableOf(true),
setRedirectUrl: {}
});
});
it('should call navigateByUrl to a 404 page, when the remote data contains a 404 error', () => {
const testRD = createFailedRemoteDataObject(undefined, new RemoteDataError(404, 'Not Found', 'Object was not found'));
observableOf(testRD).pipe(redirectOn404Or401(router)).subscribe();
observableOf(testRD).pipe(redirectOn4xx(router, authService)).subscribe();
expect(router.navigateByUrl).toHaveBeenCalledWith('/404', { skipLocationChange: true });
});
it('should call navigateByUrl to a 401 page, when the remote data contains a 401 error', () => {
const testRD = createFailedRemoteDataObject(undefined, new RemoteDataError(401, 'Unauthorized', 'The current user is unauthorized'));
it('should call navigateByUrl to a 403 page, when the remote data contains a 403 error', () => {
const testRD = createFailedRemoteDataObject(undefined, new RemoteDataError(403, 'Forbidden', 'Forbidden access'));
observableOf(testRD).pipe(redirectOn404Or401(router)).subscribe();
expect(router.navigateByUrl).toHaveBeenCalledWith('/401', { skipLocationChange: true });
observableOf(testRD).pipe(redirectOn4xx(router, authService)).subscribe();
expect(router.navigateByUrl).toHaveBeenCalledWith('/403', { skipLocationChange: true });
});
it('should not call navigateByUrl to a 404 or 401 page, when the remote data contains another error than a 404 or 401', () => {
it('should not call navigateByUrl to a 404, 403 or 401 page, when the remote data contains another error than a 404, 403 or 401', () => {
const testRD = createFailedRemoteDataObject(undefined, new RemoteDataError(500, 'Server Error', 'Something went wrong'));
observableOf(testRD).pipe(redirectOn404Or401(router)).subscribe();
observableOf(testRD).pipe(redirectOn4xx(router, authService)).subscribe();
expect(router.navigateByUrl).not.toHaveBeenCalled();
});
it('should not call navigateByUrl to a 404 or 401 page, when the remote data contains no error', () => {
it('should not call navigateByUrl to a 404, 403 or 401 page, when the remote data contains no error', () => {
const testRD = createSuccessfulRemoteDataObject(undefined);
observableOf(testRD).pipe(redirectOn404Or401(router)).subscribe();
observableOf(testRD).pipe(redirectOn4xx(router, authService)).subscribe();
expect(router.navigateByUrl).not.toHaveBeenCalled();
});
describe('when the user is not authenticated', () => {
beforeEach(() => {
(authService.isAuthenticated as jasmine.Spy).and.returnValue(observableOf(false));
});
it('should set the redirect url and navigate to login when the remote data contains a 401 error', () => {
const testRD = createFailedRemoteDataObject(undefined, new RemoteDataError(401, 'Unauthorized', 'The current user is unauthorized'));
observableOf(testRD).pipe(redirectOn4xx(router, authService)).subscribe();
expect(authService.setRedirectUrl).toHaveBeenCalled();
expect(router.navigateByUrl).toHaveBeenCalledWith('login');
});
it('should set the redirect url and navigate to login when the remote data contains a 403 error', () => {
const testRD = createFailedRemoteDataObject(undefined, new RemoteDataError(403, 'Forbidden', 'Forbidden access'));
observableOf(testRD).pipe(redirectOn4xx(router, authService)).subscribe();
expect(authService.setRedirectUrl).toHaveBeenCalled();
expect(router.navigateByUrl).toHaveBeenCalledWith('login');
});
});
});
describe('getResponseFromEntry', () => {

View File

@@ -13,8 +13,9 @@ import { MetadataField } from '../metadata/metadata-field.model';
import { MetadataSchema } from '../metadata/metadata-schema.model';
import { BrowseDefinition } from './browse-definition.model';
import { DSpaceObject } from './dspace-object.model';
import { getPageNotFoundRoute, getUnauthorizedRoute } from '../../app-routing-paths';
import { getForbiddenRoute, getPageNotFoundRoute } from '../../app-routing-paths';
import { getEndUserAgreementPath } from '../../info/info-routing-paths';
import { AuthService } from '../auth/auth.service';
/**
* This file contains custom RxJS operators that can be used in multiple places
@@ -178,29 +179,47 @@ export const getAllSucceededRemoteListPayload = () =>
* Operator that checks if a remote data object returned a 401 or 404 error
* When it does contain such an error, it will redirect the user to the related error page, without altering the current URL
* @param router The router used to navigate to a new page
* @param authService Service to check if the user is authenticated
*/
export const redirectOn404Or401 = (router: Router) =>
export const redirectOn4xx = (router: Router, authService: AuthService) =>
<T>(source: Observable<RemoteData<T>>): Observable<RemoteData<T>> =>
source.pipe(
tap((rd: RemoteData<T>) => {
observableCombineLatest(source, authService.isAuthenticated()).pipe(
map(([rd, isAuthenticated]: [RemoteData<T>, boolean]) => {
if (rd.hasFailed) {
if (rd.error.statusCode === 404) {
router.navigateByUrl(getPageNotFoundRoute(), {skipLocationChange: true});
} else if (rd.error.statusCode === 401) {
router.navigateByUrl(getUnauthorizedRoute(), {skipLocationChange: true});
} else if (rd.error.statusCode === 403 || rd.error.statusCode === 401) {
if (isAuthenticated) {
router.navigateByUrl(getForbiddenRoute(), {skipLocationChange: true});
} else {
authService.setRedirectUrl(router.url);
router.navigateByUrl('login');
}
}
}
return rd;
}));
/**
* Operator that returns a UrlTree to the unauthorized page when the boolean received is false
* @param router
* Operator that returns a UrlTree to a forbidden page or the login page when the boolean received is false
* @param router The router used to navigate to a forbidden page
* @param authService The AuthService used to determine whether or not the user is logged in
* @param redirectUrl The URL to redirect back to after logging in
*/
export const returnUnauthorizedUrlTreeOnFalse = (router: Router) =>
export const returnForbiddenUrlTreeOrLoginOnFalse = (router: Router, authService: AuthService, redirectUrl: string) =>
(source: Observable<boolean>): Observable<boolean | UrlTree> =>
source.pipe(
map((authorized: boolean) => {
return authorized ? authorized : router.parseUrl(getUnauthorizedRoute())
observableCombineLatest(source, authService.isAuthenticated()).pipe(
map(([authorized, authenticated]: [boolean, boolean]) => {
if (authorized) {
return authorized;
} else {
if (authenticated) {
return router.parseUrl(getForbiddenRoute());
} else {
authService.setRedirectUrl(redirectUrl);
return router.parseUrl('login');
}
}
}));
/**

View File

@@ -0,0 +1,10 @@
<div class="forbidden container">
<h1>403</h1>
<h2><small>{{"403.forbidden" | translate}}</small></h2>
<br/>
<p>{{"403.help" | translate}}</p>
<br/>
<p class="text-center">
<a routerLink="/home" class="btn btn-primary">{{"403.link.home-page" | translate}}</a>
</p>
</div>

View File

@@ -3,30 +3,30 @@ import { AuthService } from '../core/auth/auth.service';
import { ServerResponseService } from '../core/services/server-response.service';
/**
* This component representing the `Unauthorized` DSpace page.
* This component representing the `Forbidden` DSpace page.
*/
@Component({
selector: 'ds-unauthorized',
templateUrl: './unauthorized.component.html',
styleUrls: ['./unauthorized.component.scss']
selector: 'ds-forbidden',
templateUrl: './forbidden.component.html',
styleUrls: ['./forbidden.component.scss']
})
export class UnauthorizedComponent implements OnInit {
export class ForbiddenComponent implements OnInit {
/**
* Initialize instance variables
*
* @param {AuthService} authservice
* @param {AuthService} authService
* @param {ServerResponseService} responseService
*/
constructor(private authservice: AuthService, private responseService: ServerResponseService) {
this.responseService.setUnauthorized();
constructor(private authService: AuthService, private responseService: ServerResponseService) {
this.responseService.setForbidden();
}
/**
* Remove redirect url from the state
*/
ngOnInit(): void {
this.authservice.clearRedirectUrl();
this.authService.clearRedirectUrl();
}
}

View File

@@ -12,7 +12,7 @@ import { ProcessDataService } from '../../core/data/processes/process-data.servi
import { RemoteData } from '../../core/data/remote-data';
import { Bitstream } from '../../core/shared/bitstream.model';
import { DSpaceObject } from '../../core/shared/dspace-object.model';
import { getFirstSucceededRemoteDataPayload, redirectOn404Or401 } from '../../core/shared/operators';
import { getFirstSucceededRemoteDataPayload, redirectOn4xx } from '../../core/shared/operators';
import { URLCombiner } from '../../core/url-combiner/url-combiner';
import { AlertType } from '../../shared/alert/aletr-type';
import { hasValue } from '../../shared/empty.util';
@@ -84,7 +84,7 @@ export class ProcessDetailComponent implements OnInit {
map((data) => {
return data.process as RemoteData<Process>
}),
redirectOn404Or401(this.router)
redirectOn4xx(this.router, this.authService)
);
this.filesRD$ = this.processRD$.pipe(

View File

@@ -12,4 +12,11 @@ export class AuthServiceMock {
public getShortlivedToken(): Observable<string> {
return observableOf('token');
}
public isAuthenticated(): Observable<boolean> {
return observableOf(true);
}
public setRedirectUrl(url: string) {
}
}

View File

@@ -12,6 +12,7 @@ export const GroupMock2: Group = Object.assign(new Group(), {
href: 'https://dspace.4science.it/dspace-spring-rest/api/eperson/groups/testgroupid2',
},
subgroups: { href: 'https://dspace.4science.it/dspace-spring-rest/api/eperson/groups/testgroupid2/subgroups' },
object: { href: 'https://dspace.4science.it/dspace-spring-rest/api/eperson/groups/testgroupid2/object' },
epersons: { href: 'https://dspace.4science.it/dspace-spring-rest/api/eperson/groups/testgroupid2/epersons' }
},
_name: 'testgroupname2',
@@ -31,6 +32,7 @@ export const GroupMock: Group = Object.assign(new Group(), {
href: 'https://dspace.4science.it/dspace-spring-rest/api/eperson/groups/testgroupid',
},
subgroups: { href: 'https://dspace.4science.it/dspace-spring-rest/api/eperson/groups/testgroupid/subgroups' },
object: { href: 'https://dspace.4science.it/dspace-spring-rest/api/eperson/groups/testgroupid2/object' },
epersons: { href: 'https://dspace.4science.it/dspace-spring-rest/api/eperson/groups/testgroupid/epersons' }
},
_name: 'testgroupname',

View File

@@ -14,6 +14,7 @@ import { SharedModule } from '../../shared/shared.module';
import { CommonModule } from '@angular/common';
import { DSONameService } from '../../core/breadcrumbs/dso-name.service';
import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service';
import { AuthService } from '../../core/auth/auth.service';
describe('CollectionStatisticsPageComponent', () => {
@@ -59,6 +60,11 @@ describe('CollectionStatisticsPageComponent', () => {
getName: () => observableOf('test dso name'),
};
const authService = jasmine.createSpyObj('authService', {
isAuthenticated: observableOf(true),
setRedirectUrl: {}
});
TestBed.configureTestingModule({
imports: [
TranslateModule.forRoot(),
@@ -75,6 +81,7 @@ describe('CollectionStatisticsPageComponent', () => {
{ provide: UsageReportService, useValue: usageReportService },
{ provide: DSpaceObjectDataService, useValue: {} },
{ provide: DSONameService, useValue: nameService },
{ provide: AuthService, useValue: authService },
],
})
.compileComponents();

View File

@@ -4,6 +4,7 @@ import { UsageReportService } from '../../core/statistics/usage-report-data.serv
import { ActivatedRoute , Router} from '@angular/router';
import { Collection } from '../../core/shared/collection.model';
import { DSONameService } from '../../core/breadcrumbs/dso-name.service';
import { AuthService } from '../../core/auth/auth.service';
/**
* Component representing the statistics page for a collection.
@@ -30,12 +31,14 @@ export class CollectionStatisticsPageComponent extends StatisticsPageComponent<C
protected router: Router,
protected usageReportService: UsageReportService,
protected nameService: DSONameService,
protected authService: AuthService
) {
super(
route,
router,
usageReportService,
nameService,
authService,
);
}
}

View File

@@ -14,6 +14,7 @@ import { SharedModule } from '../../shared/shared.module';
import { CommonModule } from '@angular/common';
import { DSONameService } from '../../core/breadcrumbs/dso-name.service';
import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service';
import { AuthService } from '../../core/auth/auth.service';
describe('CommunityStatisticsPageComponent', () => {
@@ -59,6 +60,11 @@ describe('CommunityStatisticsPageComponent', () => {
getName: () => observableOf('test dso name'),
};
const authService = jasmine.createSpyObj('authService', {
isAuthenticated: observableOf(true),
setRedirectUrl: {}
});
TestBed.configureTestingModule({
imports: [
TranslateModule.forRoot(),
@@ -75,6 +81,7 @@ describe('CommunityStatisticsPageComponent', () => {
{ provide: UsageReportService, useValue: usageReportService },
{ provide: DSpaceObjectDataService, useValue: {} },
{ provide: DSONameService, useValue: nameService },
{ provide: AuthService, useValue: authService },
],
})
.compileComponents();

View File

@@ -4,6 +4,7 @@ import { UsageReportService } from '../../core/statistics/usage-report-data.serv
import { ActivatedRoute, Router } from '@angular/router';
import { Community } from '../../core/shared/community.model';
import { DSONameService } from '../../core/breadcrumbs/dso-name.service';
import { AuthService } from '../../core/auth/auth.service';
/**
* Component representing the statistics page for a community.
@@ -30,12 +31,14 @@ export class CommunityStatisticsPageComponent extends StatisticsPageComponent<Co
protected router: Router,
protected usageReportService: UsageReportService,
protected nameService: DSONameService,
protected authService: AuthService,
) {
super(
route,
router,
usageReportService,
nameService,
authService,
);
}
}

View File

@@ -14,6 +14,7 @@ import { SharedModule } from '../../shared/shared.module';
import { CommonModule } from '@angular/common';
import { DSONameService } from '../../core/breadcrumbs/dso-name.service';
import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service';
import { AuthService } from '../../core/auth/auth.service';
describe('ItemStatisticsPageComponent', () => {
@@ -59,6 +60,11 @@ describe('ItemStatisticsPageComponent', () => {
getName: () => observableOf('test dso name'),
};
const authService = jasmine.createSpyObj('authService', {
isAuthenticated: observableOf(true),
setRedirectUrl: {}
});
TestBed.configureTestingModule({
imports: [
TranslateModule.forRoot(),
@@ -75,6 +81,7 @@ describe('ItemStatisticsPageComponent', () => {
{ provide: UsageReportService, useValue: usageReportService },
{ provide: DSpaceObjectDataService, useValue: {} },
{ provide: DSONameService, useValue: nameService },
{ provide: AuthService, useValue: authService },
],
})
.compileComponents();

View File

@@ -4,6 +4,7 @@ import { UsageReportService } from '../../core/statistics/usage-report-data.serv
import { ActivatedRoute, Router } from '@angular/router';
import { Item } from '../../core/shared/item.model';
import { DSONameService } from '../../core/breadcrumbs/dso-name.service';
import { AuthService } from '../../core/auth/auth.service';
/**
* Component representing the statistics page for an item.
@@ -31,12 +32,14 @@ export class ItemStatisticsPageComponent extends StatisticsPageComponent<Item> {
protected router: Router,
protected usageReportService: UsageReportService,
protected nameService: DSONameService,
protected authService: AuthService
) {
super(
route,
router,
usageReportService,
nameService,
authService,
);
}
}

View File

@@ -14,6 +14,7 @@ import { CommonModule } from '@angular/common';
import { DSONameService } from '../../core/breadcrumbs/dso-name.service';
import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service';
import { SiteDataService } from '../../core/data/site-data.service';
import { AuthService } from '../../core/auth/auth.service';
describe('SiteStatisticsPageComponent', () => {
@@ -55,6 +56,11 @@ describe('SiteStatisticsPageComponent', () => {
}))
};
const authService = jasmine.createSpyObj('authService', {
isAuthenticated: observableOf(true),
setRedirectUrl: {}
});
TestBed.configureTestingModule({
imports: [
TranslateModule.forRoot(),
@@ -72,6 +78,7 @@ describe('SiteStatisticsPageComponent', () => {
{ provide: DSpaceObjectDataService, useValue: {} },
{ provide: DSONameService, useValue: nameService },
{ provide: SiteDataService, useValue: siteService },
{ provide: AuthService, useValue: authService },
],
})
.compileComponents();

View File

@@ -6,6 +6,7 @@ import { ActivatedRoute, Router } from '@angular/router';
import { Site } from '../../core/shared/site.model';
import { DSONameService } from '../../core/breadcrumbs/dso-name.service';
import { switchMap } from 'rxjs/operators';
import { AuthService } from '../../core/auth/auth.service';
/**
* Component representing the site-wide statistics page.
@@ -30,12 +31,14 @@ export class SiteStatisticsPageComponent extends StatisticsPageComponent<Site> {
protected usageReportService: UsageReportService,
protected nameService: DSONameService,
protected siteService: SiteDataService,
protected authService: AuthService,
) {
super(
route,
router,
usageReportService,
nameService,
authService,
);
}

View File

@@ -4,10 +4,11 @@ import { UsageReportService } from '../../core/statistics/usage-report-data.serv
import { map, switchMap } from 'rxjs/operators';
import { UsageReport } from '../../core/statistics/models/usage-report.model';
import { RemoteData } from '../../core/data/remote-data';
import { getRemoteDataPayload, getSucceededRemoteData, redirectOn404Or401 } from '../../core/shared/operators';
import { getRemoteDataPayload, getSucceededRemoteData, redirectOn4xx } from '../../core/shared/operators';
import { DSpaceObject } from '../../core/shared/dspace-object.model';
import { ActivatedRoute, Router } from '@angular/router';
import { DSONameService } from '../../core/breadcrumbs/dso-name.service';
import { AuthService } from '../../core/auth/auth.service';
/**
* Class representing an abstract statistics page component.
@@ -36,6 +37,7 @@ export abstract class StatisticsPageComponent<T extends DSpaceObject> implements
protected router: Router,
protected usageReportService: UsageReportService,
protected nameService: DSONameService,
protected authService: AuthService,
) {
}
@@ -55,7 +57,7 @@ export abstract class StatisticsPageComponent<T extends DSpaceObject> implements
protected getScope$(): Observable<DSpaceObject> {
return this.route.data.pipe(
map((data) => data.scope as RemoteData<T>),
redirectOn404Or401(this.router),
redirectOn4xx(this.router, this.authService),
getSucceededRemoteData(),
getRemoteDataPayload(),
);

View File

@@ -1,10 +0,0 @@
<div class="unauthorized container">
<h1>401</h1>
<h2><small>{{"401.unauthorized" | translate}}</small></h2>
<br/>
<p>{{"401.help" | translate}}</p>
<br/>
<p class="text-center">
<a routerLink="/home" class="btn btn-primary">{{"401.link.home-page" | translate}}</a>
</p>
</div>

View File

@@ -8,6 +8,14 @@
"403.help": "You don't have permission to access this page. You can use the button below to get back to the home page.",
"403.link.home-page": "Take me to the home page",
"403.forbidden": "forbidden",
"404.help": "We can't find the page you're looking for. The page may have been moved or deleted. You can use the button below to get back to the home page. ",
"404.link.home-page": "Take me to the home page",
@@ -280,6 +288,10 @@
"admin.access-control.groups.title": "DSpace Angular :: Groups",
"admin.access-control.groups.title.singleGroup": "DSpace Angular :: Edit Group",
"admin.access-control.groups.title.addGroup": "DSpace Angular :: New Group",
"admin.access-control.groups.head": "Groups",
"admin.access-control.groups.button.add": "Add group",
@@ -308,8 +320,15 @@
"admin.access-control.groups.notification.deleted.success": "Successfully deleted group \"{{name}}\"",
"admin.access-control.groups.notification.deleted.failure": "Failed to delete group \"{{name}}\"",
"admin.access-control.groups.notification.deleted.failure.title": "Failed to delete group \"{{name}}\"",
"admin.access-control.groups.notification.deleted.failure.content": "Cause: \"{{cause}}\"",
"admin.access-control.groups.form.alert.permanent": "This group is permanent, so it can't be edited or deleted. You can still add and remove group members using this page.",
"admin.access-control.groups.form.alert.workflowGroup": "This group cant be modified or deleted because it corresponds to a role in the submission and workflow process in the \"{{name}}\" {{comcol}}. You can delete it from the <a href='{{comcolEditRolesRoute}}'>\"assign roles\"</a> tab on the edit {{comcol}} page. You can still add and remove group members using this page.",
"admin.access-control.groups.form.head.create": "Create group",
@@ -325,6 +344,28 @@
"admin.access-control.groups.form.notification.created.failure.groupNameInUse": "Failed to create Group with name: \"{{name}}\", make sure the name is not already in use.",
"admin.access-control.groups.form.notification.edited.failure": "Failed to edit Group \"{{name}}\"",
"admin.access-control.groups.form.notification.edited.failure.groupNameInUse": "Name \"{{name}}\" already in use!",
"admin.access-control.groups.form.notification.edited.success": "Successfully edited Group \"{{name}}\"",
"admin.access-control.groups.form.actions.delete": "Delete Group",
"admin.access-control.groups.form.delete-group.modal.header": "Delete Group \"{{ dsoName }}\"",
"admin.access-control.groups.form.delete-group.modal.info": "Are you sure you want to delete Group \"{{ dsoName }}\"",
"admin.access-control.groups.form.delete-group.modal.cancel": "Cancel",
"admin.access-control.groups.form.delete-group.modal.confirm": "Delete",
"admin.access-control.groups.form.notification.deleted.success": "Successfully deleted group \"{{ name }}\"",
"admin.access-control.groups.form.notification.deleted.failure.title": "Failed to delete group \"{{ name }}\"",
"admin.access-control.groups.form.notification.deleted.failure.content": "Cause: \"{{ cause }}\"",
"admin.access-control.groups.form.members-list.head": "EPeople",
"admin.access-control.groups.form.members-list.search.head": "Add EPeople",