Merge branch 'DSpace:main' into patch-21-squashed

This commit is contained in:
Sascha Szott
2024-06-13 13:05:22 +02:00
committed by GitHub
207 changed files with 6263 additions and 11943 deletions

View File

@@ -18,6 +18,11 @@ describe('Edit Item > Edit Metadata tab', () => {
// <ds-edit-item-page> tag must be loaded
cy.get('ds-edit-item-page').should('be.visible');
// wait for all the ds-dso-edit-metadata-value components to be rendered
cy.get('ds-dso-edit-metadata-value div[role="row"]').each(($row: HTMLDivElement) => {
cy.wrap($row).find('div[role="cell"]').should('be.visible');
});
// Analyze <ds-edit-item-page> for accessibility issues
testA11y('ds-edit-item-page');
});

View File

@@ -137,7 +137,7 @@ describe('New Submission page', () => {
// Upload our DSpace logo via drag & drop onto submission form
// cy.get('div#section_upload')
cy.get('div.ds-document-drop-zone').selectFile('src/assets/images/dspace-logo.png', {
cy.get('div.ds-document-drop-zone').selectFile('src/assets/images/dspace-logo.svg', {
action: 'drag-drop',
});

View File

@@ -1,16 +1,13 @@
import { AbstractControl } from '@angular/forms';
import {
mapToCanActivate,
Route,
} from '@angular/router';
import { Route } from '@angular/router';
import {
DYNAMIC_ERROR_MESSAGES_MATCHER,
DynamicErrorMessagesMatcher,
} from '@ng-dynamic-forms/core';
import { i18nBreadcrumbResolver } from '../core/breadcrumbs/i18n-breadcrumb.resolver';
import { GroupAdministratorGuard } from '../core/data/feature-authorization/feature-authorization-guard/group-administrator.guard';
import { SiteAdministratorGuard } from '../core/data/feature-authorization/feature-authorization-guard/site-administrator.guard';
import { groupAdministratorGuard } from '../core/data/feature-authorization/feature-authorization-guard/group-administrator.guard';
import { siteAdministratorGuard } from '../core/data/feature-authorization/feature-authorization-guard/site-administrator.guard';
import {
EPERSON_PATH,
GROUP_PATH,
@@ -20,7 +17,7 @@ import { EPeopleRegistryComponent } from './epeople-registry/epeople-registry.co
import { EPersonFormComponent } from './epeople-registry/eperson-form/eperson-form.component';
import { EPersonResolver } from './epeople-registry/eperson-resolver.service';
import { GroupFormComponent } from './group-registry/group-form/group-form.component';
import { GroupPageGuard } from './group-registry/group-page.guard';
import { groupPageGuard } from './group-registry/group-page.guard';
import { GroupsRegistryComponent } from './group-registry/groups-registry.component';
/**
@@ -28,7 +25,7 @@ import { GroupsRegistryComponent } from './group-registry/groups-registry.compon
*/
export const ValidateEmailErrorStateMatcher: DynamicErrorMessagesMatcher =
(control: AbstractControl, model: any, hasFocus: boolean) => {
return (control.touched && !hasFocus) || (control.errors?.emailTaken && hasFocus);
return ( control.touched && !hasFocus ) || ( control.errors?.emailTaken && hasFocus );
};
const providers = [
@@ -46,7 +43,7 @@ export const ROUTES: Route[] = [
},
providers,
data: { title: 'admin.access-control.epeople.title', breadcrumbKey: 'admin.access-control.epeople' },
canActivate: mapToCanActivate([SiteAdministratorGuard]),
canActivate: [siteAdministratorGuard],
},
{
path: `${EPERSON_PATH}/create`,
@@ -56,7 +53,7 @@ export const ROUTES: Route[] = [
},
providers,
data: { title: 'admin.access-control.epeople.add.title', breadcrumbKey: 'admin.access-control.epeople.add' },
canActivate: mapToCanActivate([SiteAdministratorGuard]),
canActivate: [siteAdministratorGuard],
},
{
path: `${EPERSON_PATH}/:id/edit`,
@@ -67,7 +64,7 @@ export const ROUTES: Route[] = [
},
providers,
data: { title: 'admin.access-control.epeople.edit.title', breadcrumbKey: 'admin.access-control.epeople.edit' },
canActivate: mapToCanActivate([SiteAdministratorGuard]),
canActivate: [siteAdministratorGuard],
},
{
path: GROUP_PATH,
@@ -77,7 +74,7 @@ export const ROUTES: Route[] = [
},
providers,
data: { title: 'admin.access-control.groups.title', breadcrumbKey: 'admin.access-control.groups' },
canActivate: mapToCanActivate([GroupAdministratorGuard]),
canActivate: [groupAdministratorGuard],
},
{
path: `${GROUP_PATH}/create`,
@@ -90,7 +87,7 @@ export const ROUTES: Route[] = [
title: 'admin.access-control.groups.title.addGroup',
breadcrumbKey: 'admin.access-control.groups.addGroup',
},
canActivate: mapToCanActivate([GroupAdministratorGuard]),
canActivate: [groupAdministratorGuard],
},
{
path: `${GROUP_PATH}/:groupId/edit`,
@@ -103,7 +100,7 @@ export const ROUTES: Route[] = [
title: 'admin.access-control.groups.title.singleGroup',
breadcrumbKey: 'admin.access-control.groups.singleGroup',
},
canActivate: mapToCanActivate([GroupPageGuard]),
canActivate: [groupPageGuard],
},
{
path: 'bulk-access',
@@ -112,6 +109,6 @@ export const ROUTES: Route[] = [
breadcrumb: i18nBreadcrumbResolver,
},
data: { title: 'admin.access-control.bulk-access.title', breadcrumbKey: 'admin.access-control.bulk-access' },
canActivate: mapToCanActivate([SiteAdministratorGuard]),
canActivate: [siteAdministratorGuard],
},
];

View File

@@ -20,25 +20,33 @@
</tr>
</thead>
<tbody>
<tr *ngFor="let eperson of (ePeopleMembersOfGroup | async)?.page">
<td class="align-middle">{{eperson.id}}</td>
<tr *ngFor="let epersonDTO of (ePeopleMembersOfGroup | async)?.page">
<td class="align-middle">{{epersonDTO.eperson.id}}</td>
<td class="align-middle">
<a [routerLink]="getEPersonEditRoute(eperson.id)">
{{ dsoNameService.getName(eperson) }}
<a [routerLink]="getEPersonEditRoute(epersonDTO.eperson.id)">
{{ dsoNameService.getName(epersonDTO.eperson) }}
</a>
</td>
<td class="align-middle">
{{messagePrefix + '.table.email' | translate}}: {{ eperson.email ? eperson.email : '-' }}<br/>
{{messagePrefix + '.table.netid' | translate}}: {{ eperson.netid ? eperson.netid : '-' }}
{{messagePrefix + '.table.email' | translate}}: {{ epersonDTO.eperson.email ? epersonDTO.eperson.email : '-' }}<br/>
{{messagePrefix + '.table.netid' | translate}}: {{ epersonDTO.eperson.netid ? epersonDTO.eperson.netid : '-' }}
</td>
<td class="align-middle">
<div class="btn-group edit-field">
<button (click)="deleteMemberFromGroup(eperson)"
<button (click)="deleteMemberFromGroup(epersonDTO.eperson)"
*ngIf="epersonDTO.ableToDelete"
[disabled]="actionConfig.remove.disabled"
[ngClass]="['btn btn-sm', actionConfig.remove.css]"
title="{{messagePrefix + '.table.edit.buttons.remove' | translate: { name: dsoNameService.getName(eperson) } }}">
title="{{messagePrefix + '.table.edit.buttons.remove' | translate: { name: dsoNameService.getName(epersonDTO.eperson) } }}">
<i [ngClass]="actionConfig.remove.icon"></i>
</button>
<button *ngIf="!epersonDTO.ableToDelete"
(click)="addMemberToGroup(epersonDTO.eperson)"
[disabled]="actionConfig.add.disabled"
[ngClass]="['btn btn-sm', actionConfig.add.css]"
title="{{messagePrefix + '.table.edit.buttons.add' | translate: { name: dsoNameService.getName(epersonDTO.eperson) } }}">
<i [ngClass]="actionConfig.add.icon"></i>
</button>
</div>
</td>
</tr>

View File

@@ -222,13 +222,13 @@ describe('MembersListComponent', () => {
describe('if first delete button is pressed', () => {
beforeEach(() => {
spyOn(component, 'search').and.callThrough();
const deleteButton: DebugElement = fixture.debugElement.query(By.css('#ePeopleMembersOfGroup tbody .fa-trash-alt'));
deleteButton.nativeElement.click();
fixture.detectChanges();
});
it('then no ePerson remains as a member of the active group.', () => {
const epersonsFound = fixture.debugElement.queryAll(By.css('#ePeopleMembersOfGroup tbody tr'));
expect(epersonsFound.length).toEqual(0);
it('should trigger the search to add the user back to the search table', () => {
expect(component.search).toHaveBeenCalled();
});
});
});
@@ -264,13 +264,13 @@ describe('MembersListComponent', () => {
describe('if first add button is pressed', () => {
beforeEach(() => {
spyOn(component, 'search').and.callThrough();
const addButton: DebugElement = fixture.debugElement.query(By.css('#epersonsSearch tbody .fa-plus'));
addButton.nativeElement.click();
fixture.detectChanges();
});
it('then all (two) ePersons are member of the active group. No non-members left', () => {
epersonsFound = fixture.debugElement.queryAll(By.css('#epersonsSearch tbody tr'));
expect(epersonsFound.length).toEqual(0);
it('should trigger the search to remove the user from the search table', () => {
expect(component.search).toHaveBeenCalled();
});
});
});

View File

@@ -24,21 +24,29 @@ import {
} from '@ngx-translate/core';
import {
BehaviorSubject,
combineLatest as observableCombineLatest,
Observable,
ObservedValueOf,
of as observableOf,
Subscription,
} from 'rxjs';
import {
defaultIfEmpty,
map,
switchMap,
take,
} from 'rxjs/operators';
import { DSONameService } from '../../../../core/breadcrumbs/dso-name.service';
import { PaginatedList } from '../../../../core/data/paginated-list.model';
import {
buildPaginatedList,
PaginatedList,
} from '../../../../core/data/paginated-list.model';
import { RemoteData } from '../../../../core/data/remote-data';
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 { EpersonDtoModel } from '../../../../core/eperson/models/eperson-dto.model';
import { Group } from '../../../../core/eperson/models/group.model';
import { PaginationService } from '../../../../core/pagination/pagination.service';
import {
@@ -137,7 +145,7 @@ export class MembersListComponent implements OnInit, OnDestroy {
/**
* List of EPeople members of currently active group being edited
*/
ePeopleMembersOfGroup: BehaviorSubject<PaginatedList<EPerson>> = new BehaviorSubject<PaginatedList<EPerson>>(undefined);
ePeopleMembersOfGroup: BehaviorSubject<PaginatedList<EpersonDtoModel>> = new BehaviorSubject(undefined);
/**
* Pagination config used to display the list of EPeople that are result of EPeople search
@@ -226,10 +234,35 @@ export class MembersListComponent implements OnInit, OnDestroy {
return rd;
}
}),
getRemoteDataPayload())
.subscribe((paginatedListOfEPersons: PaginatedList<EPerson>) => {
this.ePeopleMembersOfGroup.next(paginatedListOfEPersons);
switchMap((epersonListRD: RemoteData<PaginatedList<EPerson>>) => {
const dtos$ = observableCombineLatest([...epersonListRD.payload.page.map((member: EPerson) => {
const dto$: Observable<EpersonDtoModel> = observableCombineLatest(
this.isMemberOfGroup(member), (isMember: ObservedValueOf<Observable<boolean>>) => {
const epersonDtoModel: EpersonDtoModel = new EpersonDtoModel();
epersonDtoModel.eperson = member;
epersonDtoModel.ableToDelete = isMember;
return epersonDtoModel;
});
return dto$;
})]);
return dtos$.pipe(defaultIfEmpty([]), map((dtos: EpersonDtoModel[]) => {
return buildPaginatedList(epersonListRD.payload.pageInfo, dtos);
}));
}),
).subscribe((paginatedListOfDTOs: PaginatedList<EpersonDtoModel>) => {
this.ePeopleMembersOfGroup.next(paginatedListOfDTOs);
}),
);
}
/**
* We always return true since this is only used by the top section (which represents all the users part of the group
* in {@link MembersListComponent})
*
* @param possibleMember EPerson that is a possible member (being tested) of the group currently being edited
*/
isMemberOfGroup(possibleMember: EPerson): Observable<boolean> {
return observableOf(true);
}
/**

View File

@@ -1,14 +1,24 @@
import {
TestBed,
waitForAsync,
} from '@angular/core/testing';
import {
ActivatedRouteSnapshot,
Router,
UrlTree,
} from '@angular/router';
import { of as observableOf } from 'rxjs';
import {
Observable,
of as observableOf,
} from 'rxjs';
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';
import { HALEndpointService } from '../../core/shared/hal-endpoint.service';
import { GroupPageGuard } from './group-page.guard';
import { groupPageGuard } from './group-page.guard';
jasmine.DEFAULT_TIMEOUT_INTERVAL = 10000; // Increase timeout to 10 seconds
describe('GroupPageGuard', () => {
const groupsEndpointUrl = 'https://test.org/api/eperson/groups';
@@ -20,42 +30,54 @@ describe('GroupPageGuard', () => {
},
} as unknown as ActivatedRouteSnapshot;
let guard: GroupPageGuard;
let halEndpointService: HALEndpointService;
let authorizationService: AuthorizationDataService;
let router: Router;
let authService: AuthService;
beforeEach(() => {
function init() {
halEndpointService = jasmine.createSpyObj(['getEndpoint']);
(halEndpointService as any).getEndpoint.and.returnValue(observableOf(groupsEndpointUrl));
( halEndpointService as any ).getEndpoint.and.returnValue(observableOf(groupsEndpointUrl));
authorizationService = jasmine.createSpyObj(['isAuthorized']);
// NOTE: value is set in beforeEach
router = jasmine.createSpyObj(['parseUrl']);
(router as any).parseUrl.and.returnValue = {};
( router as any ).parseUrl.and.returnValue = {};
authService = jasmine.createSpyObj(['isAuthenticated']);
(authService as any).isAuthenticated.and.returnValue(observableOf(true));
( authService as any ).isAuthenticated.and.returnValue(observableOf(true));
guard = new GroupPageGuard(halEndpointService, authorizationService, router, authService);
TestBed.configureTestingModule({
providers: [
{ provide: AuthorizationDataService, useValue: authorizationService },
{ provide: Router, useValue: router },
{ provide: AuthService, useValue: authService },
{ provide: HALEndpointService, useValue: halEndpointService },
],
});
}
beforeEach(waitForAsync(() => {
init();
}));
it('should be created', () => {
expect(guard).toBeTruthy();
expect(groupPageGuard).toBeTruthy();
});
describe('canActivate', () => {
describe('when the current user can manage the group', () => {
beforeEach(() => {
(authorizationService as any).isAuthorized.and.returnValue(observableOf(true));
( authorizationService as any ).isAuthorized.and.returnValue(observableOf(true));
});
it('should return true', (done) => {
guard.canActivate(
routeSnapshotWithGroupId, { url: 'current-url' } as any,
).subscribe((result) => {
const result$ = TestBed.runInInjectionContext(() => {
return groupPageGuard()(routeSnapshotWithGroupId, { url: 'current-url' } as any);
}) as Observable<boolean | UrlTree>;
result$.subscribe((result) => {
expect(authorizationService.isAuthorized).toHaveBeenCalledWith(
FeatureID.CanManageGroup, groupEndpointUrl, undefined,
);
@@ -71,15 +93,18 @@ describe('GroupPageGuard', () => {
});
it('should not return true', (done) => {
guard.canActivate(
routeSnapshotWithGroupId, { url: 'current-url' } as any,
).subscribe((result) => {
const result$ = TestBed.runInInjectionContext(() => {
return groupPageGuard()(routeSnapshotWithGroupId, { url: 'current-url' } as any);
}) as Observable<boolean | UrlTree>;
result$.subscribe((result) => {
expect(authorizationService.isAuthorized).toHaveBeenCalledWith(
FeatureID.CanManageGroup, groupEndpointUrl, undefined,
);
expect(result).not.toBeTrue();
done();
});
});
});
});

View File

@@ -1,7 +1,7 @@
import { Injectable } from '@angular/core';
import { inject } from '@angular/core';
import {
ActivatedRouteSnapshot,
Router,
CanActivateFn,
RouterStateSnapshot,
} from '@angular/router';
import {
@@ -10,34 +10,29 @@ import {
} from 'rxjs';
import { map } from 'rxjs/operators';
import { AuthService } from '../../core/auth/auth.service';
import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service';
import { SomeFeatureAuthorizationGuard } from '../../core/data/feature-authorization/feature-authorization-guard/some-feature-authorization.guard';
import {
someFeatureAuthorizationGuard,
StringGuardParamFn,
} from '../../core/data/feature-authorization/feature-authorization-guard/some-feature-authorization.guard';
import { FeatureID } from '../../core/data/feature-authorization/feature-id';
import { HALEndpointService } from '../../core/shared/hal-endpoint.service';
@Injectable({
providedIn: 'root',
})
export class GroupPageGuard extends SomeFeatureAuthorizationGuard {
const defaultGroupPageGetObjectUrl: StringGuardParamFn = (
route: ActivatedRouteSnapshot,
state: RouterStateSnapshot,
): Observable<string> => {
const halEndpointService = inject(HALEndpointService);
const groupsEndpoint = 'groups';
protected groupsEndpoint = 'groups';
constructor(protected halEndpointService: HALEndpointService,
protected authorizationService: AuthorizationDataService,
protected router: Router,
protected authService: AuthService) {
super(authorizationService, router, authService);
}
getFeatureIDs(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<FeatureID[]> {
return observableOf([FeatureID.CanManageGroup]);
}
getObjectUrl(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<string> {
return this.halEndpointService.getEndpoint(this.groupsEndpoint).pipe(
return halEndpointService.getEndpoint(groupsEndpoint).pipe(
map(groupsUrl => `${groupsUrl}/${route?.params?.groupId}`),
);
}
};
}
export const groupPageGuard = (
getObjectUrl = defaultGroupPageGetObjectUrl,
getEPersonUuid?: StringGuardParamFn,
): CanActivateFn => someFeatureAuthorizationGuard(
() => observableOf([FeatureID.CanManageGroup]),
getObjectUrl,
getEPersonUuid);

View File

@@ -1,18 +1,15 @@
import {
mapToCanActivate,
Route,
} from '@angular/router';
import { Route } from '@angular/router';
import { i18nBreadcrumbResolver } from '../../core/breadcrumbs/i18n-breadcrumb.resolver';
import { notifyInfoGuard } from '../../core/coar-notify/notify-info/notify-info.guard';
import { SiteAdministratorGuard } from '../../core/data/feature-authorization/feature-authorization-guard/site-administrator.guard';
import { siteAdministratorGuard } from '../../core/data/feature-authorization/feature-authorization-guard/site-administrator.guard';
import { AdminNotifyDashboardComponent } from './admin-notify-dashboard.component';
import { AdminNotifyIncomingComponent } from './admin-notify-logs/admin-notify-incoming/admin-notify-incoming.component';
import { AdminNotifyOutgoingComponent } from './admin-notify-logs/admin-notify-outgoing/admin-notify-outgoing.component';
export const ROUTES: Route[] = [
{
canActivate: [...mapToCanActivate([SiteAdministratorGuard]), notifyInfoGuard],
canActivate: [siteAdministratorGuard, notifyInfoGuard],
path: '',
resolve: {
breadcrumb: i18nBreadcrumbResolver,
@@ -30,7 +27,7 @@ export const ROUTES: Route[] = [
breadcrumb: i18nBreadcrumbResolver,
},
component: AdminNotifyIncomingComponent,
canActivate: [...mapToCanActivate([SiteAdministratorGuard]), notifyInfoGuard],
canActivate: [siteAdministratorGuard, notifyInfoGuard],
data: {
title: 'admin.notify.dashboard.page.title',
breadcrumbKey: 'admin.notify.dashboard',
@@ -42,7 +39,7 @@ export const ROUTES: Route[] = [
breadcrumb: i18nBreadcrumbResolver,
},
component: AdminNotifyOutgoingComponent,
canActivate: [...mapToCanActivate([SiteAdministratorGuard]), notifyInfoGuard],
canActivate: [siteAdministratorGuard, notifyInfoGuard],
data: {
title: 'admin.notify.dashboard.page.title',
breadcrumbKey: 'admin.notify.dashboard',

View File

@@ -1,6 +1,5 @@
import {
InMemoryScrollingOptions,
mapToCanActivate,
Route,
RouterConfigOptions,
} from '@angular/router';
@@ -26,12 +25,12 @@ import { COLLECTION_MODULE_PATH } from './collection-page/collection-page-routin
import { COMMUNITY_MODULE_PATH } from './community-page/community-page-routing-paths';
import { authBlockingGuard } from './core/auth/auth-blocking.guard';
import { authenticatedGuard } from './core/auth/authenticated.guard';
import { GroupAdministratorGuard } from './core/data/feature-authorization/feature-authorization-guard/group-administrator.guard';
import { SiteAdministratorGuard } from './core/data/feature-authorization/feature-authorization-guard/site-administrator.guard';
import { SiteRegisterGuard } from './core/data/feature-authorization/feature-authorization-guard/site-register.guard';
import { EndUserAgreementCurrentUserGuard } from './core/end-user-agreement/end-user-agreement-current-user.guard';
import { groupAdministratorGuard } from './core/data/feature-authorization/feature-authorization-guard/group-administrator.guard';
import { siteAdministratorGuard } from './core/data/feature-authorization/feature-authorization-guard/site-administrator.guard';
import { siteRegisterGuard } from './core/data/feature-authorization/feature-authorization-guard/site-register.guard';
import { endUserAgreementCurrentUserGuard } from './core/end-user-agreement/end-user-agreement-current-user.guard';
import { reloadGuard } from './core/reload/reload.guard';
import { ForgotPasswordCheckGuard } from './core/rest-property/forgot-password-check-guard.guard';
import { forgotPasswordCheckGuard } from './core/rest-property/forgot-password-check-guard.guard';
import { ServerCheckGuard } from './core/server-check/server-check.guard';
import { ThemedForbiddenComponent } from './forbidden/themed-forbidden.component';
import { ITEM_MODULE_PATH } from './item-page/item-page-routing-paths';
@@ -66,105 +65,105 @@ export const APP_ROUTES: Route[] = [
.then((m) => m.ROUTES),
data: { showBreadcrumbs: false },
providers: [provideSuggestionNotificationsState()],
canActivate: mapToCanActivate([EndUserAgreementCurrentUserGuard]),
canActivate: [endUserAgreementCurrentUserGuard],
},
{
path: 'community-list',
loadChildren: () => import('./community-list-page/community-list-page-routes')
.then((m) => m.ROUTES),
canActivate: mapToCanActivate([EndUserAgreementCurrentUserGuard]),
canActivate: [endUserAgreementCurrentUserGuard],
},
{
path: 'id',
loadChildren: () => import('./lookup-by-id/lookup-by-id-routes')
.then((m) => m.ROUTES),
canActivate: mapToCanActivate([EndUserAgreementCurrentUserGuard]),
canActivate: [endUserAgreementCurrentUserGuard],
},
{
path: 'handle',
loadChildren: () => import('./lookup-by-id/lookup-by-id-routes')
.then((m) => m.ROUTES),
canActivate: mapToCanActivate([EndUserAgreementCurrentUserGuard]),
canActivate: [endUserAgreementCurrentUserGuard],
},
{
path: REGISTER_PATH,
loadChildren: () => import('./register-page/register-page-routes')
.then((m) => m.ROUTES),
canActivate: mapToCanActivate([SiteRegisterGuard]),
canActivate: [siteRegisterGuard],
},
{
path: FORGOT_PASSWORD_PATH,
loadChildren: () => import('./forgot-password/forgot-password-routes')
.then((m) => m.ROUTES),
canActivate: mapToCanActivate([EndUserAgreementCurrentUserGuard, ForgotPasswordCheckGuard]),
canActivate: [endUserAgreementCurrentUserGuard, forgotPasswordCheckGuard],
},
{
path: COMMUNITY_MODULE_PATH,
loadChildren: () => import('./community-page/community-page-routes')
.then((m) => m.ROUTES),
canActivate: mapToCanActivate([EndUserAgreementCurrentUserGuard]),
canActivate: [endUserAgreementCurrentUserGuard],
},
{
path: COLLECTION_MODULE_PATH,
loadChildren: () => import('./collection-page/collection-page-routes')
.then((m) => m.ROUTES),
canActivate: mapToCanActivate([EndUserAgreementCurrentUserGuard]),
canActivate: [endUserAgreementCurrentUserGuard],
},
{
path: ITEM_MODULE_PATH,
loadChildren: () => import('./item-page/item-page-routes')
.then((m) => m.ROUTES),
canActivate: mapToCanActivate([EndUserAgreementCurrentUserGuard]),
canActivate: [endUserAgreementCurrentUserGuard],
},
{
path: 'entities/:entity-type',
loadChildren: () => import('./item-page/item-page-routes')
.then((m) => m.ROUTES),
canActivate: mapToCanActivate([EndUserAgreementCurrentUserGuard]),
canActivate: [endUserAgreementCurrentUserGuard],
},
{
path: LEGACY_BITSTREAM_MODULE_PATH,
loadChildren: () => import('./bitstream-page/bitstream-page-routes')
.then((m) => m.ROUTES),
canActivate: mapToCanActivate([EndUserAgreementCurrentUserGuard]),
canActivate: [endUserAgreementCurrentUserGuard],
},
{
path: BITSTREAM_MODULE_PATH,
loadChildren: () => import('./bitstream-page/bitstream-page-routes')
.then((m) => m.ROUTES),
canActivate: mapToCanActivate([EndUserAgreementCurrentUserGuard]),
canActivate: [endUserAgreementCurrentUserGuard],
},
{
path: 'mydspace',
loadChildren: () => import('./my-dspace-page/my-dspace-page-routes')
.then((m) => m.ROUTES),
providers: [provideSuggestionNotificationsState()],
canActivate: [authenticatedGuard, ...mapToCanActivate([EndUserAgreementCurrentUserGuard])],
canActivate: [authenticatedGuard, endUserAgreementCurrentUserGuard],
},
{
path: 'search',
loadChildren: () => import('./search-page/search-page-routes')
.then((m) => m.ROUTES),
canActivate: mapToCanActivate([EndUserAgreementCurrentUserGuard]),
canActivate: [endUserAgreementCurrentUserGuard],
},
{
path: 'browse',
loadChildren: () => import('./browse-by/browse-by-page-routes')
.then((m) => m.ROUTES),
canActivate: mapToCanActivate([EndUserAgreementCurrentUserGuard]),
canActivate: [endUserAgreementCurrentUserGuard],
},
{
path: ADMIN_MODULE_PATH,
loadChildren: () => import('./admin/admin-routes')
.then((m) => m.ROUTES),
canActivate: mapToCanActivate([SiteAdministratorGuard, EndUserAgreementCurrentUserGuard]),
canActivate: [siteAdministratorGuard, endUserAgreementCurrentUserGuard],
},
{
path: NOTIFICATIONS_MODULE_PATH,
loadChildren: () => import('./quality-assurance-notifications-pages/notifications-pages-routes')
.then((m) => m.ROUTES),
providers: [provideSuggestionNotificationsState()],
canActivate: [authenticatedGuard, ...mapToCanActivate([EndUserAgreementCurrentUserGuard])],
canActivate: [authenticatedGuard, endUserAgreementCurrentUserGuard],
},
{
path: 'login',
@@ -181,47 +180,47 @@ export const APP_ROUTES: Route[] = [
loadChildren: () => import('./submit-page/submit-page-routes')
.then((m) => m.ROUTES),
providers: [provideSubmissionState()],
canActivate: mapToCanActivate([EndUserAgreementCurrentUserGuard]),
canActivate: [endUserAgreementCurrentUserGuard],
},
{
path: 'import-external',
loadChildren: () => import('./import-external-page/import-external-page-routes')
.then((m) => m.ROUTES),
canActivate: mapToCanActivate([EndUserAgreementCurrentUserGuard]),
canActivate: [endUserAgreementCurrentUserGuard],
},
{
path: 'workspaceitems',
loadChildren: () => import('./workspaceitems-edit-page/workspaceitems-edit-page-routes')
.then((m) => m.ROUTES),
providers: [provideSubmissionState()],
canActivate: mapToCanActivate([EndUserAgreementCurrentUserGuard]),
canActivate: [endUserAgreementCurrentUserGuard],
},
{
path: WORKFLOW_ITEM_MODULE_PATH,
providers: [provideSubmissionState()],
loadChildren: () => import('./workflowitems-edit-page/workflowitems-edit-page-routes')
.then((m) => m.ROUTES),
canActivate: mapToCanActivate([EndUserAgreementCurrentUserGuard]),
canActivate: [endUserAgreementCurrentUserGuard],
},
{
path: PROFILE_MODULE_PATH,
loadChildren: () => import('./profile-page/profile-page-routes')
.then((m) => m.ROUTES),
providers: [provideSuggestionNotificationsState()],
canActivate: [authenticatedGuard, ...mapToCanActivate([EndUserAgreementCurrentUserGuard])],
canActivate: [authenticatedGuard, endUserAgreementCurrentUserGuard],
},
{
path: PROCESS_MODULE_PATH,
loadChildren: () => import('./process-page/process-page-routes')
.then((m) => m.ROUTES),
canActivate: [authenticatedGuard, ...mapToCanActivate([EndUserAgreementCurrentUserGuard])],
canActivate: [authenticatedGuard, endUserAgreementCurrentUserGuard],
},
{
path: SUGGESTION_MODULE_PATH,
loadChildren: () => import('./suggestions-page/suggestions-page-routes')
.then((m) => m.ROUTES),
providers: [provideSuggestionNotificationsState()],
canActivate: [authenticatedGuard, ...mapToCanActivate([EndUserAgreementCurrentUserGuard])],
canActivate: [authenticatedGuard, endUserAgreementCurrentUserGuard],
},
{
path: INFO_MODULE_PATH,
@@ -230,7 +229,7 @@ export const APP_ROUTES: Route[] = [
{
path: REQUEST_COPY_MODULE_PATH,
loadChildren: () => import('./request-copy/request-copy-routes').then((m) => m.ROUTES),
canActivate: mapToCanActivate([EndUserAgreementCurrentUserGuard]),
canActivate: [endUserAgreementCurrentUserGuard],
},
{
path: FORBIDDEN_PATH,
@@ -240,7 +239,7 @@ export const APP_ROUTES: Route[] = [
path: 'statistics',
loadChildren: () => import('./statistics-page/statistics-page-routes')
.then((m) => m.ROUTES),
canActivate: mapToCanActivate([EndUserAgreementCurrentUserGuard]),
canActivate: [endUserAgreementCurrentUserGuard],
},
{
path: HEALTH_PAGE_PATH,
@@ -250,7 +249,7 @@ export const APP_ROUTES: Route[] = [
{
path: ACCESS_CONTROL_MODULE_PATH,
loadChildren: () => import('./access-control/access-control-routes').then((m) => m.ROUTES),
canActivate: mapToCanActivate([GroupAdministratorGuard, EndUserAgreementCurrentUserGuard]),
canActivate: [groupAdministratorGuard, endUserAgreementCurrentUserGuard],
},
{
path: 'subscriptions',

View File

@@ -32,6 +32,7 @@ import {
Observable,
} from 'rxjs';
import {
delay,
distinctUntilChanged,
take,
withLatestFrom,
@@ -136,7 +137,10 @@ export class AppComponent implements OnInit, AfterViewInit {
}
ngAfterViewInit() {
this.router.events.subscribe((event) => {
this.router.events.pipe(
// delay(0) to prevent "Expression has changed after it was checked" errors
delay(0),
).subscribe((event) => {
if (event instanceof NavigationStart) {
distinctNext(this.isRouteLoading$, true);
} else if (

View File

@@ -0,0 +1,81 @@
import { TestBed } from '@angular/core/testing';
import {
Router,
UrlTree,
} from '@angular/router';
import {
Observable,
of as observableOf,
} from 'rxjs';
import { AuthService } from 'src/app/core/auth/auth.service';
import { AuthorizationDataService } from 'src/app/core/data/feature-authorization/authorization-data.service';
import { FeatureID } from 'src/app/core/data/feature-authorization/feature-id';
import { BitstreamDataService } from '../core/data/bitstream-data.service';
import { Bitstream } from '../core/shared/bitstream.model';
import { createSuccessfulRemoteDataObject$ } from '../shared/remote-data.utils';
import { bitstreamPageAuthorizationsGuard } from './bitstream-page-authorizations.guard';
describe('bitstreamPageAuthorizationsGuard', () => {
let authorizationService: AuthorizationDataService;
let authService: AuthService;
let router: Router;
let route;
let parentRoute;
let bitstreamService: BitstreamDataService;
let bitstream: Bitstream;
let uuid = '1234-abcdef-54321-fedcba';
let bitstreamSelfLink = 'test.url/1234-abcdef-54321-fedcba';
beforeEach(() => {
authorizationService = jasmine.createSpyObj('authorizationService', {
isAuthorized: observableOf(true),
});
router = jasmine.createSpyObj('router', {
parseUrl: {},
navigateByUrl: undefined,
});
authService = jasmine.createSpyObj('authService', {
isAuthenticated: observableOf(true),
});
parentRoute = {
params: {
id: '3e1a5327-dabb-41ff-af93-e6cab9d032f0',
},
};
route = {
params: {},
parent: parentRoute,
};
bitstream = new Bitstream();
bitstream.uuid = uuid;
bitstream._links = { self: { href: bitstreamSelfLink } } as any;
bitstreamService = jasmine.createSpyObj('bitstreamService', { findById: createSuccessfulRemoteDataObject$(bitstream) });
TestBed.configureTestingModule({
providers: [
{ provide: AuthorizationDataService, useValue: authorizationService },
{ provide: Router, useValue: router },
{ provide: AuthService, useValue: authService },
{ provide: BitstreamDataService, useValue: bitstreamService },
],
});
});
it('should call authorizationService.isAuthorized with the appropriate arguments', (done) => {
const result$ = TestBed.runInInjectionContext(() => {
return bitstreamPageAuthorizationsGuard(route, { url: 'current-url' } as any);
}) as Observable<boolean | UrlTree>;
result$.subscribe((result) => {
expect(authorizationService.isAuthorized).toHaveBeenCalledWith(
FeatureID.CanManagePolicies,
bitstreamSelfLink,
undefined,
);
done();
});
});
});

View File

@@ -0,0 +1,16 @@
import { CanActivateFn } from '@angular/router';
import { of as observableOf } from 'rxjs';
import { dsoPageSingleFeatureGuard } from '../core/data/feature-authorization/feature-authorization-guard/dso-page-single-feature.guard';
import { FeatureID } from '../core/data/feature-authorization/feature-id';
import { bitstreamPageResolver } from './bitstream-page.resolver';
/**
* Guard for preventing unauthorized access to certain {@link Bitstream} pages requiring specific authorizations.
* Checks authorization rights for managing policies.
*/
export const bitstreamPageAuthorizationsGuard: CanActivateFn =
dsoPageSingleFeatureGuard(
() => bitstreamPageResolver,
() => observableOf(FeatureID.CanManagePolicies),
);

View File

@@ -10,8 +10,9 @@ import { resourcePolicyTargetResolver } from '../shared/resource-policies/resolv
import { BitstreamAuthorizationsComponent } from './bitstream-authorizations/bitstream-authorizations.component';
import { BitstreamDownloadPageComponent } from './bitstream-download-page/bitstream-download-page.component';
import { bitstreamPageResolver } from './bitstream-page.resolver';
import { bitstreamPageAuthorizationsGuard } from './bitstream-page-authorizations.guard';
import { ThemedEditBitstreamPageComponent } from './edit-bitstream-page/themed-edit-bitstream-page.component';
import { legacyBitstreamUrlResolver } from './legacy-bitstream-url.resolver';
import { legacyBitstreamURLRedirectGuard } from './legacy-bitstream-url-redirect.guard';
const EDIT_BITSTREAM_PATH = ':id/edit';
const EDIT_BITSTREAM_AUTHORIZATIONS_PATH = ':id/authorizations';
@@ -23,18 +24,12 @@ export const ROUTES: Route[] = [
{
// Resolve XMLUI bitstream download URLs
path: 'handle/:prefix/:suffix/:filename',
component: BitstreamDownloadPageComponent,
resolve: {
bitstream: legacyBitstreamUrlResolver,
},
canActivate: [legacyBitstreamURLRedirectGuard],
},
{
// Resolve JSPUI bitstream download URLs
path: ':prefix/:suffix/:sequence_id/:filename',
component: BitstreamDownloadPageComponent,
resolve: {
bitstream: legacyBitstreamUrlResolver,
},
canActivate: [legacyBitstreamURLRedirectGuard],
},
{
// Resolve angular bitstream download URLs
@@ -55,6 +50,7 @@ export const ROUTES: Route[] = [
},
{
path: EDIT_BITSTREAM_AUTHORIZATIONS_PATH,
canActivate: [bitstreamPageAuthorizationsGuard],
children: [
{
path: 'create',

View File

@@ -0,0 +1,158 @@
import { cold } from 'jasmine-marbles';
import { EMPTY } from 'rxjs';
import { PAGE_NOT_FOUND_PATH } from '../app-routing-paths';
import { BitstreamDataService } from '../core/data/bitstream-data.service';
import { RemoteData } from '../core/data/remote-data';
import { RequestEntryState } from '../core/data/request-entry-state.model';
import { BrowserHardRedirectService } from '../core/services/browser-hard-redirect.service';
import { HardRedirectService } from '../core/services/hard-redirect.service';
import { Bitstream } from '../core/shared/bitstream.model';
import { RouterStub } from '../shared/testing/router.stub';
import { legacyBitstreamURLRedirectGuard } from './legacy-bitstream-url-redirect.guard';
describe('legacyBitstreamURLRedirectGuard', () => {
let resolver: any;
let bitstreamDataService: BitstreamDataService;
let remoteDataMocks: { [type: string]: RemoteData<Bitstream> };
let route;
let state;
let hardRedirectService: HardRedirectService;
let router: RouterStub;
let bitstream: Bitstream;
beforeEach(() => {
route = {
params: {},
queryParams: {},
};
router = new RouterStub();
hardRedirectService = new BrowserHardRedirectService(window.location);
state = {};
bitstream = Object.assign(new Bitstream(), {
uuid: 'bitstream-id',
});
remoteDataMocks = {
RequestPending: new RemoteData(undefined, 0, 0, RequestEntryState.RequestPending, undefined, undefined, undefined),
ResponsePending: new RemoteData(undefined, 0, 0, RequestEntryState.ResponsePending, undefined, undefined, undefined),
Success: new RemoteData(0, 0, 0, RequestEntryState.Success, undefined, bitstream, 200),
NoContent: new RemoteData(0, 0, 0, RequestEntryState.Success, undefined, undefined, 204),
Error: new RemoteData(0, 0, 0, RequestEntryState.Error, 'Internal server error', undefined, 500),
};
bitstreamDataService = {
findByItemHandle: () => undefined,
} as any;
resolver = legacyBitstreamURLRedirectGuard;
});
describe(`resolve`, () => {
describe(`For JSPUI-style URLs`, () => {
beforeEach(() => {
spyOn(bitstreamDataService, 'findByItemHandle').and.returnValue(EMPTY);
route = Object.assign({}, route, {
params: {
prefix: '123456789',
suffix: '1234',
filename: 'some-file.pdf',
sequence_id: '5',
},
});
});
it(`should call findByItemHandle with the handle, sequence id, and filename from the route`, () => {
resolver(route, state, bitstreamDataService, hardRedirectService, router);
expect(bitstreamDataService.findByItemHandle).toHaveBeenCalledWith(
`${route.params.prefix}/${route.params.suffix}`,
route.params.sequence_id,
route.params.filename,
);
});
});
describe(`For XMLUI-style URLs`, () => {
describe(`when there is a sequenceId query parameter`, () => {
beforeEach(() => {
spyOn(bitstreamDataService, 'findByItemHandle').and.returnValue(EMPTY);
route = Object.assign({}, route, {
params: {
prefix: '123456789',
suffix: '1234',
filename: 'some-file.pdf',
},
queryParams: {
sequenceId: '5',
},
});
});
it(`should call findByItemHandle with the handle and filename from the route, and the sequence ID from the queryParams`, () => {
resolver(route, state, bitstreamDataService, hardRedirectService, router);
expect(bitstreamDataService.findByItemHandle).toHaveBeenCalledWith(
`${route.params.prefix}/${route.params.suffix}`,
route.queryParams.sequenceId,
route.params.filename,
);
});
});
describe(`when there's no sequenceId query parameter`, () => {
beforeEach(() => {
spyOn(bitstreamDataService, 'findByItemHandle').and.returnValue(EMPTY);
route = Object.assign({}, route, {
params: {
prefix: '123456789',
suffix: '1234',
filename: 'some-file.pdf',
},
});
});
it(`should call findByItemHandle with the handle, and filename from the route`, () => {
resolver(route, state, bitstreamDataService, hardRedirectService, router);
expect(bitstreamDataService.findByItemHandle).toHaveBeenCalledWith(
`${route.params.prefix}/${route.params.suffix}`,
undefined,
route.params.filename,
);
});
});
});
describe('should return and complete after the RemoteData has...', () => {
it('...failed', () => {
spyOn(router, 'createUrlTree').and.callThrough();
spyOn(bitstreamDataService, 'findByItemHandle').and.returnValue(cold('a-b-c', {
a: remoteDataMocks.RequestPending,
b: remoteDataMocks.ResponsePending,
c: remoteDataMocks.Error,
}));
resolver(route, state, bitstreamDataService, hardRedirectService, router).subscribe(() => {
expect(bitstreamDataService.findByItemHandle).toHaveBeenCalled();
expect(router.createUrlTree).toHaveBeenCalledWith([PAGE_NOT_FOUND_PATH]);
});
});
it('...succeeded without content', () => {
spyOn(router, 'createUrlTree').and.callThrough();
spyOn(bitstreamDataService, 'findByItemHandle').and.returnValue(cold('a-b-c', {
a: remoteDataMocks.RequestPending,
b: remoteDataMocks.ResponsePending,
c: remoteDataMocks.NoContent,
}));
resolver(route, state, bitstreamDataService, hardRedirectService, router).subscribe(() => {
expect(bitstreamDataService.findByItemHandle).toHaveBeenCalled();
expect(router.createUrlTree).toHaveBeenCalledWith([PAGE_NOT_FOUND_PATH]);
});
});
it('...succeeded', () => {
spyOn(hardRedirectService, 'redirect');
spyOn(bitstreamDataService, 'findByItemHandle').and.returnValue(cold('a-b-c', {
a: remoteDataMocks.RequestPending,
b: remoteDataMocks.ResponsePending,
c: remoteDataMocks.Success,
}));
resolver(route, state, bitstreamDataService, hardRedirectService, router).subscribe(() => {
expect(bitstreamDataService.findByItemHandle).toHaveBeenCalled();
expect(hardRedirectService.redirect).toHaveBeenCalledWith(new URL(`/bitstreams/${bitstream.uuid}/download`, window.location.origin).href, 301);
});
});
});
});
});

View File

@@ -0,0 +1,57 @@
import { inject } from '@angular/core';
import {
ActivatedRouteSnapshot,
CanActivateFn,
Router,
RouterStateSnapshot,
UrlTree,
} from '@angular/router';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { PAGE_NOT_FOUND_PATH } from '../app-routing-paths';
import { BitstreamDataService } from '../core/data/bitstream-data.service';
import { RemoteData } from '../core/data/remote-data';
import { HardRedirectService } from '../core/services/hard-redirect.service';
import { Bitstream } from '../core/shared/bitstream.model';
import { getFirstCompletedRemoteData } from '../core/shared/operators';
import { hasNoValue } from '../shared/empty.util';
/**
* Redirects to a bitstream based on the handle of the item, and the sequence id or the filename of the
* bitstream. In production mode the status code will also be set the status code to 301 marking it as a permanent URL
* redirect for bots to the regular bitstream download Page.
*
* @returns Either a {@link UrlTree} to the 404 page when the url isn't a valid format or false in order to make the
* user wait until the {@link HardRedirectService#redirect} was performed
*/
export const legacyBitstreamURLRedirectGuard: CanActivateFn = (
route: ActivatedRouteSnapshot,
state: RouterStateSnapshot,
bitstreamDataService: BitstreamDataService = inject(BitstreamDataService),
serverHardRedirectService: HardRedirectService = inject(HardRedirectService),
router: Router = inject(Router),
): Observable<UrlTree | false> => {
const prefix = route.params.prefix;
const suffix = route.params.suffix;
const filename = route.params.filename;
let sequenceId = route.params.sequence_id;
if (hasNoValue(sequenceId)) {
sequenceId = route.queryParams.sequenceId;
}
return bitstreamDataService.findByItemHandle(
`${prefix}/${suffix}`,
sequenceId,
filename,
).pipe(
getFirstCompletedRemoteData(),
map((rd: RemoteData<Bitstream>) => {
if (rd.hasSucceeded && !rd.hasNoContent) {
serverHardRedirectService.redirect(new URL(`/bitstreams/${rd.payload.uuid}/download`, serverHardRedirectService.getCurrentOrigin()).href, 301);
return false;
} else {
return router.createUrlTree([PAGE_NOT_FOUND_PATH]);
}
}),
);
};

View File

@@ -1,146 +0,0 @@
import { EMPTY } from 'rxjs';
import { TestScheduler } from 'rxjs/testing';
import { BitstreamDataService } from '../core/data/bitstream-data.service';
import { RemoteData } from '../core/data/remote-data';
import { RequestEntryState } from '../core/data/request-entry-state.model';
import { legacyBitstreamUrlResolver } from './legacy-bitstream-url.resolver';
describe(`legacyBitstreamUrlResolver`, () => {
let resolver: any;
let bitstreamDataService: BitstreamDataService;
let testScheduler;
let remoteDataMocks;
let route;
let state;
beforeEach(() => {
testScheduler = new TestScheduler((actual, expected) => {
expect(actual).toEqual(expected);
});
route = {
params: {},
queryParams: {},
};
state = {};
remoteDataMocks = {
RequestPending: new RemoteData(undefined, 0, 0, RequestEntryState.RequestPending, undefined, undefined, undefined),
ResponsePending: new RemoteData(undefined, 0, 0, RequestEntryState.ResponsePending, undefined, undefined, undefined),
Success: new RemoteData(0, 0, 0, RequestEntryState.Success, undefined, {}, 200),
Error: new RemoteData(0, 0, 0, RequestEntryState.Error, 'Internal server error', undefined, 500),
};
bitstreamDataService = {
findByItemHandle: () => undefined,
} as any;
resolver = legacyBitstreamUrlResolver;
});
describe(`resolve`, () => {
describe(`For JSPUI-style URLs`, () => {
beforeEach(() => {
spyOn(bitstreamDataService, 'findByItemHandle').and.returnValue(EMPTY);
route = Object.assign({}, route, {
params: {
prefix: '123456789',
suffix: '1234',
filename: 'some-file.pdf',
sequence_id: '5',
},
});
});
it(`should call findByItemHandle with the handle, sequence id, and filename from the route`, () => {
testScheduler.run(() => {
resolver(route, state, bitstreamDataService);
expect(bitstreamDataService.findByItemHandle).toHaveBeenCalledWith(
`${route.params.prefix}/${route.params.suffix}`,
route.params.sequence_id,
route.params.filename,
);
});
});
});
describe(`For XMLUI-style URLs`, () => {
describe(`when there is a sequenceId query parameter`, () => {
beforeEach(() => {
spyOn(bitstreamDataService, 'findByItemHandle').and.returnValue(EMPTY);
route = Object.assign({}, route, {
params: {
prefix: '123456789',
suffix: '1234',
filename: 'some-file.pdf',
},
queryParams: {
sequenceId: '5',
},
});
});
it(`should call findByItemHandle with the handle and filename from the route, and the sequence ID from the queryParams`, () => {
testScheduler.run(() => {
resolver(route, state, bitstreamDataService);
expect(bitstreamDataService.findByItemHandle).toHaveBeenCalledWith(
`${route.params.prefix}/${route.params.suffix}`,
route.queryParams.sequenceId,
route.params.filename,
);
});
});
});
describe(`when there's no sequenceId query parameter`, () => {
beforeEach(() => {
spyOn(bitstreamDataService, 'findByItemHandle').and.returnValue(EMPTY);
route = Object.assign({}, route, {
params: {
prefix: '123456789',
suffix: '1234',
filename: 'some-file.pdf',
},
});
});
it(`should call findByItemHandle with the handle, and filename from the route`, () => {
testScheduler.run(() => {
resolver(route, state, bitstreamDataService);
expect(bitstreamDataService.findByItemHandle).toHaveBeenCalledWith(
`${route.params.prefix}/${route.params.suffix}`,
undefined,
route.params.filename,
);
});
});
});
});
describe(`should return and complete after the remotedata has...`, () => {
it(`...failed`, () => {
testScheduler.run(({ cold, expectObservable }) => {
spyOn(bitstreamDataService, 'findByItemHandle').and.returnValue(cold('a-b-c', {
a: remoteDataMocks.RequestPending,
b: remoteDataMocks.ResponsePending,
c: remoteDataMocks.Error,
}));
const expected = '----(c|)';
const values = {
c: remoteDataMocks.Error,
};
expectObservable(resolver(route, state, bitstreamDataService)).toBe(expected, values);
});
});
it(`...succeeded`, () => {
testScheduler.run(({ cold, expectObservable }) => {
spyOn(bitstreamDataService, 'findByItemHandle').and.returnValue(cold('a-b-c', {
a: remoteDataMocks.RequestPending,
b: remoteDataMocks.ResponsePending,
c: remoteDataMocks.Success,
}));
const expected = '----(c|)';
const values = {
c: remoteDataMocks.Success,
};
expectObservable(resolver(route, state, bitstreamDataService)).toBe(expected, values);
});
});
});
});
});

View File

@@ -1,46 +0,0 @@
import { inject } from '@angular/core';
import {
ActivatedRouteSnapshot,
ResolveFn,
RouterStateSnapshot,
} from '@angular/router';
import { Observable } from 'rxjs';
import { BitstreamDataService } from '../core/data/bitstream-data.service';
import { RemoteData } from '../core/data/remote-data';
import { Bitstream } from '../core/shared/bitstream.model';
import { getFirstCompletedRemoteData } from '../core/shared/operators';
import { hasNoValue } from '../shared/empty.util';
/**
* Resolve a bitstream based on the handle of the item, and the sequence id or the filename of the
* bitstream
*
* @param {ActivatedRouteSnapshot} route The current ActivatedRouteSnapshot
* @param {RouterStateSnapshot} state The current RouterStateSnapshot
* @param {BitstreamDataService} bitstreamDataService
* @returns Observable<<RemoteData<Item>> Emits the found bitstream based on the parameters in
* current route, or an error if something went wrong
*/
export const legacyBitstreamUrlResolver: ResolveFn<RemoteData<Bitstream>> = (
route: ActivatedRouteSnapshot,
state: RouterStateSnapshot,
bitstreamDataService: BitstreamDataService = inject(BitstreamDataService),
): Observable<RemoteData<Bitstream>> => {
const prefix = route.params.prefix;
const suffix = route.params.suffix;
const filename = route.params.filename;
let sequenceId = route.params.sequence_id;
if (hasNoValue(sequenceId)) {
sequenceId = route.queryParams.sequenceId;
}
return bitstreamDataService.findByItemHandle(
`${prefix}/${suffix}`,
sequenceId,
filename,
).pipe(
getFirstCompletedRemoteData(),
);
};

View File

@@ -7,8 +7,10 @@
} }}
</h1>
<div class="mb-3">
<ds-vocabulary-treeview [vocabularyOptions]=vocabularyOptions
<ds-vocabulary-treeview [description]="description"
[vocabularyOptions]=vocabularyOptions
[multiSelect]="true"
[showAdd]="false"
(select)="onSelect($event)"
(deselect)="onDeselect($event)">
</ds-vocabulary-treeview>

View File

@@ -14,7 +14,10 @@ import {
Params,
RouterLink,
} from '@angular/router';
import { TranslateModule } from '@ngx-translate/core';
import {
TranslateModule,
TranslateService,
} from '@ngx-translate/core';
import {
BehaviorSubject,
Observable,
@@ -124,6 +127,11 @@ export class BrowseByTaxonomyComponent implements OnInit, OnChanges, OnDestroy {
*/
browseDefinition$: Observable<BrowseDefinition>;
/**
* Browse description
*/
description: string;
/**
* Subscriptions to track
*/
@@ -131,6 +139,7 @@ export class BrowseByTaxonomyComponent implements OnInit, OnChanges, OnDestroy {
public constructor(
protected route: ActivatedRoute,
protected translate: TranslateService,
) {
}
@@ -141,9 +150,11 @@ export class BrowseByTaxonomyComponent implements OnInit, OnChanges, OnDestroy {
}),
);
this.subs.push(this.browseDefinition$.subscribe((browseDefinition: HierarchicalBrowseDefinition) => {
this.selectedItems = [];
this.facetType = browseDefinition.facetType;
this.vocabularyName = browseDefinition.vocabulary;
this.vocabularyOptions = { name: this.vocabularyName, closed: true };
this.description = this.translate.instant(`browse.metadata.${this.vocabularyName}.tree.descrption`);
}));
this.subs.push(this.scope$.subscribe(() => {
this.updateQueryParams();

View File

@@ -1,43 +1,16 @@
import { Injectable } from '@angular/core';
import {
ActivatedRouteSnapshot,
ResolveFn,
Router,
RouterStateSnapshot,
} from '@angular/router';
import {
Observable,
of as observableOf,
} from 'rxjs';
import { CanActivateFn } from '@angular/router';
import { of as observableOf } from 'rxjs';
import { AuthService } from '../core/auth/auth.service';
import { AuthorizationDataService } from '../core/data/feature-authorization/authorization-data.service';
import { DsoPageSingleFeatureGuard } from '../core/data/feature-authorization/feature-authorization-guard/dso-page-single-feature.guard';
import { dsoPageSingleFeatureGuard } from '../core/data/feature-authorization/feature-authorization-guard/dso-page-single-feature.guard';
import { FeatureID } from '../core/data/feature-authorization/feature-id';
import { RemoteData } from '../core/data/remote-data';
import { Collection } from '../core/shared/collection.model';
import { collectionPageResolver } from './collection-page.resolver';
@Injectable({
providedIn: 'root',
})
/**
* Guard for preventing unauthorized access to certain {@link Collection} pages requiring administrator rights
*/
export class CollectionPageAdministratorGuard extends DsoPageSingleFeatureGuard<Collection> {
protected resolver: ResolveFn<RemoteData<Collection>> = collectionPageResolver;
constructor(protected authorizationService: AuthorizationDataService,
protected router: Router,
protected authService: AuthService) {
super(authorizationService, router, authService);
}
/**
* Check administrator authorization rights
*/
getFeatureID(next: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<FeatureID> {
return observableOf(FeatureID.AdministratorOf);
}
}
export const collectionPageAdministratorGuard: CanActivateFn =
dsoPageSingleFeatureGuard(
() => collectionPageResolver,
() => observableOf(FeatureID.AdministratorOf),
);

View File

@@ -1,7 +1,4 @@
import {
mapToCanActivate,
Route,
} from '@angular/router';
import { Route } from '@angular/router';
import { browseByGuard } from '../browse-by/browse-by-guard';
import { browseByI18nBreadcrumbResolver } from '../browse-by/browse-by-i18n-breadcrumb.resolver';
@@ -15,7 +12,7 @@ import { dsoEditMenuResolver } from '../shared/dso-page/dso-edit-menu.resolver';
import { LinkMenuItemModel } from '../shared/menu/menu-item/models/link.model';
import { MenuItemType } from '../shared/menu/menu-item-type.model';
import { collectionPageResolver } from './collection-page.resolver';
import { CollectionPageAdministratorGuard } from './collection-page-administrator.guard';
import { collectionPageAdministratorGuard } from './collection-page-administrator.guard';
import {
COLLECTION_CREATE_PATH,
COLLECTION_EDIT_PATH,
@@ -65,7 +62,7 @@ export const ROUTES: Route[] = [
path: COLLECTION_EDIT_PATH,
loadChildren: () => import('./edit-collection-page/edit-collection-page-routes')
.then((m) => m.ROUTES),
canActivate: mapToCanActivate([CollectionPageAdministratorGuard]),
canActivate: [collectionPageAdministratorGuard],
},
{
path: 'delete',

View File

@@ -1,10 +1,7 @@
import {
mapToCanActivate,
Route,
} from '@angular/router';
import { Route } from '@angular/router';
import { i18nBreadcrumbResolver } from '../../core/breadcrumbs/i18n-breadcrumb.resolver';
import { CollectionAdministratorGuard } from '../../core/data/feature-authorization/feature-authorization-guard/collection-administrator.guard';
import { collectionAdministratorGuard } from '../../core/data/feature-authorization/feature-authorization-guard/collection-administrator.guard';
import { ResourcePolicyCreateComponent } from '../../shared/resource-policies/create/resource-policy-create.component';
import { ResourcePolicyEditComponent } from '../../shared/resource-policies/edit/resource-policy-edit.component';
import { resourcePolicyResolver } from '../../shared/resource-policies/resolvers/resource-policy.resolver';
@@ -30,7 +27,7 @@ export const ROUTES: Route[] = [
},
data: { breadcrumbKey: 'collection.edit' },
component: EditCollectionPageComponent,
canActivate: mapToCanActivate([CollectionAdministratorGuard]),
canActivate: [collectionAdministratorGuard],
children: [
{
path: '',

View File

@@ -1,43 +1,16 @@
import { Injectable } from '@angular/core';
import {
ActivatedRouteSnapshot,
ResolveFn,
Router,
RouterStateSnapshot,
} from '@angular/router';
import {
Observable,
of as observableOf,
} from 'rxjs';
import { CanActivateFn } from '@angular/router';
import { of as observableOf } from 'rxjs';
import { AuthService } from '../core/auth/auth.service';
import { AuthorizationDataService } from '../core/data/feature-authorization/authorization-data.service';
import { DsoPageSingleFeatureGuard } from '../core/data/feature-authorization/feature-authorization-guard/dso-page-single-feature.guard';
import { dsoPageSingleFeatureGuard } from '../core/data/feature-authorization/feature-authorization-guard/dso-page-single-feature.guard';
import { FeatureID } from '../core/data/feature-authorization/feature-id';
import { RemoteData } from '../core/data/remote-data';
import { Community } from '../core/shared/community.model';
import { communityPageResolver } from './community-page.resolver';
@Injectable({
providedIn: 'root',
})
/**
* Guard for preventing unauthorized access to certain {@link Community} pages requiring administrator rights
*/
export class CommunityPageAdministratorGuard extends DsoPageSingleFeatureGuard<Community> {
protected resolver: ResolveFn<RemoteData<Community>> = communityPageResolver;
constructor(protected authorizationService: AuthorizationDataService,
protected router: Router,
protected authService: AuthService) {
super(authorizationService, router, authService);
}
/**
* Check administrator authorization rights
*/
getFeatureID(next: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<FeatureID> {
return observableOf(FeatureID.AdministratorOf);
}
}
export const communityPageAdministratorGuard: CanActivateFn =
dsoPageSingleFeatureGuard(
() => communityPageResolver,
() => observableOf(FeatureID.AdministratorOf),
);

View File

@@ -1,7 +1,4 @@
import {
mapToCanActivate,
Route,
} from '@angular/router';
import { Route } from '@angular/router';
import { browseByGuard } from '../browse-by/browse-by-guard';
import { browseByI18nBreadcrumbResolver } from '../browse-by/browse-by-i18n-breadcrumb.resolver';
@@ -14,7 +11,7 @@ import { dsoEditMenuResolver } from '../shared/dso-page/dso-edit-menu.resolver';
import { LinkMenuItemModel } from '../shared/menu/menu-item/models/link.model';
import { MenuItemType } from '../shared/menu/menu-item-type.model';
import { communityPageResolver } from './community-page.resolver';
import { CommunityPageAdministratorGuard } from './community-page-administrator.guard';
import { communityPageAdministratorGuard } from './community-page-administrator.guard';
import {
COMMUNITY_CREATE_PATH,
COMMUNITY_EDIT_PATH,
@@ -62,7 +59,7 @@ export const ROUTES: Route[] = [
path: COMMUNITY_EDIT_PATH,
loadChildren: () => import('./edit-community-page/edit-community-page-routes')
.then((m) => m.ROUTES),
canActivate: mapToCanActivate([CommunityPageAdministratorGuard]),
canActivate: [communityPageAdministratorGuard],
},
{
path: 'delete',

View File

@@ -1,10 +1,7 @@
import {
mapToCanActivate,
Route,
} from '@angular/router';
import { Route } from '@angular/router';
import { i18nBreadcrumbResolver } from '../../core/breadcrumbs/i18n-breadcrumb.resolver';
import { CommunityAdministratorGuard } from '../../core/data/feature-authorization/feature-authorization-guard/community-administrator.guard';
import { communityAdministratorGuard } from '../../core/data/feature-authorization/feature-authorization-guard/community-administrator.guard';
import { ResourcePolicyCreateComponent } from '../../shared/resource-policies/create/resource-policy-create.component';
import { ResourcePolicyEditComponent } from '../../shared/resource-policies/edit/resource-policy-edit.component';
import { resourcePolicyResolver } from '../../shared/resource-policies/resolvers/resource-policy.resolver';
@@ -28,7 +25,7 @@ export const ROUTES: Route[] = [
},
data: { breadcrumbKey: 'community.edit' },
component: EditCommunityPageComponent,
canActivate: mapToCanActivate([CommunityAdministratorGuard]),
canActivate: [communityAdministratorGuard],
children: [
{
path: '',

View File

@@ -209,6 +209,11 @@ describe('DeleteDataImpl', () => {
method: RestRequestMethod.DELETE,
href: 'some-href?copyVirtualMetadata=a&copyVirtualMetadata=b&copyVirtualMetadata=c',
}));
const callback = (rdbService.buildFromRequestUUIDAndAwait as jasmine.Spy).calls.argsFor(0)[1];
callback();
expect(service.invalidateByHref).toHaveBeenCalledWith('some-href');
done();
});
});

View File

@@ -75,15 +75,16 @@ export class DeleteDataImpl<T extends CacheableObject> extends IdentifiableDataS
deleteByHref(href: string, copyVirtualMetadata?: string[]): Observable<RemoteData<NoContent>> {
const requestId = this.requestService.generateRequestId();
let deleteHref: string = href;
if (copyVirtualMetadata) {
copyVirtualMetadata.forEach((id) =>
href += (href.includes('?') ? '&' : '?')
deleteHref += (deleteHref.includes('?') ? '&' : '?')
+ 'copyVirtualMetadata='
+ id,
);
}
const request = new DeleteRequest(requestId, href);
const request = new DeleteRequest(requestId, deleteHref);
if (hasValue(this.responseMsToLive)) {
request.responseMsToLive = this.responseMsToLive;
}

View File

@@ -5,10 +5,7 @@
*
* http://www.dspace.org/license/
*/
import {
Observable,
of as observableOf,
} from 'rxjs';
import { of as observableOf } from 'rxjs';
import { TestScheduler } from 'rxjs/testing';
import { getMockRemoteDataBuildService } from '../../../shared/mocks/remote-data-build.service.mock';
@@ -18,14 +15,14 @@ import { followLink } from '../../../shared/utils/follow-link-config.model';
import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service';
import { ObjectCacheService } from '../../cache/object-cache.service';
import { HALEndpointService } from '../../shared/hal-endpoint.service';
import { FindListOptions } from '../find-list-options.model';
import { RemoteData } from '../remote-data';
import { RequestService } from '../request.service';
import { RequestEntryState } from '../request-entry-state.model';
import { EMBED_SEPARATOR } from './base-data.service';
import { IdentifiableDataService } from './identifiable-data.service';
const endpoint = 'https://rest.api/core';
const base = 'https://rest.api/core';
const endpoint = 'test';
class TestService extends IdentifiableDataService<any> {
constructor(
@@ -34,11 +31,7 @@ class TestService extends IdentifiableDataService<any> {
protected objectCache: ObjectCacheService,
protected halService: HALEndpointService,
) {
super(undefined, requestService, rdbService, objectCache, halService);
}
public getBrowseEndpoint(options: FindListOptions = {}, linkPath: string = this.linkPath): Observable<string> {
return observableOf(endpoint);
super(endpoint, requestService, rdbService, objectCache, halService);
}
}
@@ -55,7 +48,7 @@ describe('IdentifiableDataService', () => {
function initTestService(): TestService {
requestService = getMockRequestService();
halService = new HALEndpointServiceStub('url') as any;
halService = new HALEndpointServiceStub(base) as any;
rdbService = getMockRemoteDataBuildService();
objectCache = {
@@ -147,4 +140,12 @@ describe('IdentifiableDataService', () => {
expect(result).toEqual(expected);
});
});
describe('invalidateById', () => {
it('should invalidate the correct resource by href', () => {
spyOn(service, 'invalidateByHref').and.returnValue(observableOf(true));
service.invalidateById('123');
expect(service.invalidateByHref).toHaveBeenCalledWith(`${base}/${endpoint}/123`);
});
});
});

View File

@@ -6,7 +6,11 @@
* http://www.dspace.org/license/
*/
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import {
map,
switchMap,
take,
} from 'rxjs/operators';
import { FollowLinkConfig } from '../../../shared/utils/follow-link-config.model';
import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service';
@@ -81,4 +85,19 @@ export class IdentifiableDataService<T extends CacheableObject> extends BaseData
return this.getEndpoint().pipe(
map((endpoint: string) => this.getIDHref(endpoint, resourceID, ...linksToFollow)));
}
/**
* Invalidate a cached resource by its identifier
* @param resourceID the identifier of the resource to invalidate
*/
invalidateById(resourceID: string): Observable<boolean> {
const ok$ = this.getIDHrefObs(resourceID).pipe(
take(1),
switchMap((href) => this.invalidateByHref(href)),
);
ok$.subscribe();
return ok$;
}
}

View File

@@ -72,7 +72,7 @@ describe('AuthorizationDataService', () => {
const ePersonUuid = 'fake-eperson-uuid';
function createExpected(providedObjectUrl: string, providedEPersonUuid?: string, providedFeatureId?: FeatureID): FindListOptions {
const searchParams = [new RequestParam('uri', providedObjectUrl)];
const searchParams = [new RequestParam('uri', providedObjectUrl, false)];
if (hasValue(providedFeatureId)) {
searchParams.push(new RequestParam('feature', providedFeatureId));
}

View File

@@ -147,7 +147,8 @@ export class AuthorizationDataService extends BaseDataService<Authorization> imp
if (isNotEmpty(options.searchParams)) {
params = [...options.searchParams];
}
params.push(new RequestParam('uri', objectUrl));
// TODO fix encode the uri parameter in the self link in the backend and set encodeValue to true afterwards
params.push(new RequestParam('uri', objectUrl, false));
if (hasValue(featureId)) {
params.push(new RequestParam('feature', featureId));
}

View File

@@ -1,35 +1,13 @@
import { Injectable } from '@angular/core';
import {
ActivatedRouteSnapshot,
Router,
RouterStateSnapshot,
} from '@angular/router';
import {
Observable,
of as observableOf,
} from 'rxjs';
import { CanActivateFn } from '@angular/router';
import { of as observableOf } from 'rxjs';
import { AuthService } from '../../../auth/auth.service';
import { AuthorizationDataService } from '../authorization-data.service';
import { FeatureID } from '../feature-id';
import { SingleFeatureAuthorizationGuard } from './single-feature-authorization.guard';
import { singleFeatureAuthorizationGuard } from './single-feature-authorization.guard';
/**
* Prevent unauthorized activating and loading of routes when the current authenticated user
* isn't a Collection administrator
*/
@Injectable({
providedIn: 'root',
})
export class CollectionAdministratorGuard extends SingleFeatureAuthorizationGuard {
constructor(protected authorizationService: AuthorizationDataService, protected router: Router, protected authService: AuthService) {
super(authorizationService, router, authService);
}
/**
* Check group management rights
*/
getFeatureID(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<FeatureID> {
return observableOf(FeatureID.IsCollectionAdmin);
}
}
export const collectionAdministratorGuard: CanActivateFn =
singleFeatureAuthorizationGuard(() => observableOf(FeatureID.IsCollectionAdmin));

View File

@@ -1,35 +1,13 @@
import { Injectable } from '@angular/core';
import {
ActivatedRouteSnapshot,
Router,
RouterStateSnapshot,
} from '@angular/router';
import {
Observable,
of as observableOf,
} from 'rxjs';
import { CanActivateFn } from '@angular/router';
import { of as observableOf } from 'rxjs';
import { AuthService } from '../../../auth/auth.service';
import { AuthorizationDataService } from '../authorization-data.service';
import { FeatureID } from '../feature-id';
import { SingleFeatureAuthorizationGuard } from './single-feature-authorization.guard';
import { singleFeatureAuthorizationGuard } from './single-feature-authorization.guard';
/**
* Prevent unauthorized activating and loading of routes when the current authenticated user
* isn't a Community administrator
*/
@Injectable({
providedIn: 'root',
})
export class CommunityAdministratorGuard extends SingleFeatureAuthorizationGuard {
constructor(protected authorizationService: AuthorizationDataService, protected router: Router, protected authService: AuthService) {
super(authorizationService, router, authService);
}
/**
* Check group management rights
*/
getFeatureID(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<FeatureID> {
return observableOf(FeatureID.IsCommunityAdmin);
}
}
export const communityAdministratorGuard: CanActivateFn =
singleFeatureAuthorizationGuard(() => observableOf(FeatureID.IsCommunityAdmin));

View File

@@ -1,8 +1,8 @@
import { TestBed } from '@angular/core/testing';
import {
ActivatedRouteSnapshot,
ResolveFn,
Router,
RouterStateSnapshot,
UrlTree,
} from '@angular/router';
import {
Observable,
@@ -12,52 +12,39 @@ import {
import { createSuccessfulRemoteDataObject$ } from '../../../../shared/remote-data.utils';
import { AuthService } from '../../../auth/auth.service';
import { DSpaceObject } from '../../../shared/dspace-object.model';
import { Item } from '../../../shared/item.model';
import { RemoteData } from '../../remote-data';
import { AuthorizationDataService } from '../authorization-data.service';
import { FeatureID } from '../feature-id';
import { DsoPageSingleFeatureGuard } from './dso-page-single-feature.guard';
import { dsoPageSingleFeatureGuard } from './dso-page-single-feature.guard';
import {
defaultDSOGetObjectUrl,
getRouteWithDSOId,
} from './dso-page-some-feature.guard';
const object = {
self: 'test-selflink',
} as DSpaceObject;
const testResolver: ResolveFn<RemoteData<any>> = () => createSuccessfulRemoteDataObject$(object);
/**
* Test implementation of abstract class DsoPageSingleFeatureGuard
*/
class DsoPageSingleFeatureGuardImpl extends DsoPageSingleFeatureGuard<any> {
protected resolver: ResolveFn<RemoteData<Item>> = testResolver;
constructor(protected authorizationService: AuthorizationDataService,
protected router: Router,
protected authService: AuthService,
protected featureID: FeatureID) {
super(authorizationService, router, authService);
}
getFeatureID(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<FeatureID> {
return observableOf(this.featureID);
}
}
describe('DsoPageSingleFeatureGuard', () => {
let guard: DsoPageSingleFeatureGuard<any>;
let authorizationService: AuthorizationDataService;
let router: Router;
let authService: AuthService;
let resolver: ResolveFn<RemoteData<any>>;
let object: DSpaceObject;
let route;
let parentRoute;
let featureId: FeatureID;
function init() {
object = {
self: 'test-selflink',
} as DSpaceObject;
authorizationService = jasmine.createSpyObj('authorizationService', {
isAuthorized: observableOf(true),
});
router = jasmine.createSpyObj('router', {
parseUrl: {},
});
resolver = () => createSuccessfulRemoteDataObject$(object);
authService = jasmine.createSpyObj('authService', {
isAuthenticated: observableOf(true),
});
@@ -71,16 +58,25 @@ describe('DsoPageSingleFeatureGuard', () => {
},
parent: parentRoute,
};
guard = new DsoPageSingleFeatureGuardImpl(authorizationService, router, authService, undefined);
featureId = FeatureID.LoginOnBehalfOf;
TestBed.configureTestingModule({
providers: [
{ provide: AuthorizationDataService, useValue: authorizationService },
{ provide: Router, useValue: router },
{ provide: AuthService, useValue: authService },
],
});
}
beforeEach(() => {
init();
});
describe('getObjectUrl', () => {
describe('defaultDSOGetObjectUrl', () => {
it('should return the resolved object\'s selflink', (done) => {
guard.getObjectUrl(route, undefined).subscribe((selflink) => {
defaultDSOGetObjectUrl(resolver)(route, undefined).subscribe((selflink) => {
expect(selflink).toEqual(object.self);
done();
});
@@ -89,8 +85,23 @@ describe('DsoPageSingleFeatureGuard', () => {
describe('getRouteWithDSOId', () => {
it('should return the route that has the UUID of the DSO', () => {
const foundRoute = (guard as any).getRouteWithDSOId(route);
const foundRoute = getRouteWithDSOId(route);
expect(foundRoute).toBe(parentRoute);
});
});
describe('dsoPageSingleFeatureGuard', () => {
it('should call authorizationService.isAuthenticated with the appropriate arguments', (done) => {
const result$ = TestBed.runInInjectionContext(() => {
return dsoPageSingleFeatureGuard(
() => resolver, () => observableOf(featureId),
)(route, { url: 'current-url' } as any);
}) as Observable<boolean | UrlTree>;
result$.subscribe(() => {
expect(authorizationService.isAuthorized).toHaveBeenCalledWith(featureId, object.self, undefined);
done();
});
});
});
});

View File

@@ -1,31 +1,27 @@
import {
ActivatedRouteSnapshot,
CanActivateFn,
ResolveFn,
RouterStateSnapshot,
} from '@angular/router';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { DSpaceObject } from '../../../shared/dspace-object.model';
import { RemoteData } from '../../remote-data';
import { FeatureID } from '../feature-id';
import { DsoPageSomeFeatureGuard } from './dso-page-some-feature.guard';
import { dsoPageSomeFeatureGuard } from './dso-page-some-feature.guard';
import { SingleFeatureGuardParamFn } from './single-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 DsoPageSingleFeatureGuard<T extends DSpaceObject> extends DsoPageSomeFeatureGuard<T> {
/**
* The features to check authorization for
*/
getFeatureIDs(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<FeatureID[]> {
return this.getFeatureID(route, state).pipe(
map((featureID) => [featureID]),
);
}
/**
* The type of feature to check authorization for
* Override this method to define a feature
*/
abstract getFeatureID(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<FeatureID>;
}
export const dsoPageSingleFeatureGuard = <T extends DSpaceObject> (
getResolveFn: () => ResolveFn<RemoteData<T>>,
getFeatureID: SingleFeatureGuardParamFn,
): CanActivateFn => dsoPageSomeFeatureGuard(
getResolveFn,
(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<FeatureID[]> => getFeatureID(route, state).pipe(
map((featureID: FeatureID) => [featureID]),
));

View File

@@ -1,8 +1,8 @@
import { TestBed } from '@angular/core/testing';
import {
ActivatedRouteSnapshot,
ResolveFn,
Router,
RouterStateSnapshot,
UrlTree,
} from '@angular/router';
import {
Observable,
@@ -12,53 +12,39 @@ import {
import { createSuccessfulRemoteDataObject$ } from '../../../../shared/remote-data.utils';
import { AuthService } from '../../../auth/auth.service';
import { DSpaceObject } from '../../../shared/dspace-object.model';
import { Item } from '../../../shared/item.model';
import { RemoteData } from '../../remote-data';
import { AuthorizationDataService } from '../authorization-data.service';
import { FeatureID } from '../feature-id';
import { DsoPageSomeFeatureGuard } from './dso-page-some-feature.guard';
import {
defaultDSOGetObjectUrl,
dsoPageSomeFeatureGuard,
getRouteWithDSOId,
} from './dso-page-some-feature.guard';
const object = {
self: 'test-selflink',
} as DSpaceObject;
const testResolver: ResolveFn<RemoteData<any>> = () => createSuccessfulRemoteDataObject$(object);
/**
* Test implementation of abstract class DsoPageSomeFeatureGuard
*/
class DsoPageSomeFeatureGuardImpl extends DsoPageSomeFeatureGuard<any> {
protected resolver: ResolveFn<RemoteData<Item>> = testResolver;
constructor(protected authorizationService: AuthorizationDataService,
protected router: Router,
protected authService: AuthService,
protected featureIDs: FeatureID[]) {
super(authorizationService, router, authService);
}
getFeatureIDs(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<FeatureID[]> {
return observableOf(this.featureIDs);
}
}
describe('DsoPageSomeFeatureGuard', () => {
let guard: DsoPageSomeFeatureGuard<any>;
describe('dsoPageSomeFeatureGuard and its functions', () => {
let authorizationService: AuthorizationDataService;
let router: Router;
let authService: AuthService;
let resolver: ResolveFn<RemoteData<any>>;
let object: DSpaceObject;
let route;
let parentRoute;
let featureIds: FeatureID[];
function init() {
object = {
self: 'test-selflink',
} as DSpaceObject;
featureIds = [FeatureID.LoginOnBehalfOf, FeatureID.CanDelete];
authorizationService = jasmine.createSpyObj('authorizationService', {
isAuthorized: observableOf(true),
});
router = jasmine.createSpyObj('router', {
parseUrl: {},
});
resolver = () => createSuccessfulRemoteDataObject$(object);
authService = jasmine.createSpyObj('authService', {
isAuthenticated: observableOf(true),
});
@@ -72,16 +58,25 @@ describe('DsoPageSomeFeatureGuard', () => {
},
parent: parentRoute,
};
guard = new DsoPageSomeFeatureGuardImpl(authorizationService, router, authService, []);
TestBed.configureTestingModule({
providers: [
{ provide: AuthorizationDataService, useValue: authorizationService },
{ provide: Router, useValue: router },
{ provide: AuthService, useValue: authService },
],
});
}
beforeEach(() => {
init();
});
describe('getObjectUrl', () => {
describe('defaultDSOGetObjectUrl', () => {
it('should return the resolved object\'s selflink', (done) => {
guard.getObjectUrl(route, undefined).subscribe((selflink) => {
defaultDSOGetObjectUrl(resolver)(route, undefined).subscribe((selflink) => {
expect(selflink).toEqual(object.self);
done();
});
@@ -90,8 +85,26 @@ describe('DsoPageSomeFeatureGuard', () => {
describe('getRouteWithDSOId', () => {
it('should return the route that has the UUID of the DSO', () => {
const foundRoute = (guard as any).getRouteWithDSOId(route);
const foundRoute = getRouteWithDSOId(route);
expect(foundRoute).toBe(parentRoute);
});
});
describe('dsoPageSomeFeatureGuard', () => {
it('should call authorizationService.isAuthenticated with the appropriate arguments', (done) => {
const result$ = TestBed.runInInjectionContext(() => {
return dsoPageSomeFeatureGuard(
() => resolver, () => observableOf(featureIds),
)(route, { url: 'current-url' } as any);
}) as Observable<boolean | UrlTree>;
result$.subscribe(() => {
featureIds.forEach((featureId: FeatureID) => {
expect(authorizationService.isAuthorized).toHaveBeenCalledWith(featureId, object.self, undefined);
});
done();
});
});
});
});

View File

@@ -1,7 +1,7 @@
import {
ActivatedRouteSnapshot,
CanActivateFn,
ResolveFn,
Router,
RouterStateSnapshot,
} from '@angular/router';
import { Observable } from 'rxjs';
@@ -11,47 +11,50 @@ import {
hasNoValue,
hasValue,
} from '../../../../shared/empty.util';
import { AuthService } from '../../../auth/auth.service';
import { DSpaceObject } from '../../../shared/dspace-object.model';
import { getAllSucceededRemoteDataPayload } from '../../../shared/operators';
import { RemoteData } from '../../remote-data';
import { AuthorizationDataService } from '../authorization-data.service';
import { SomeFeatureAuthorizationGuard } from './some-feature-authorization.guard';
import { FeatureID } from '../feature-id';
import {
someFeatureAuthorizationGuard,
SomeFeatureGuardParamFn,
StringGuardParamFn,
} from './some-feature-authorization.guard';
export declare type DSOGetObjectURlFn = <T extends DSpaceObject>(resolve: ResolveFn<RemoteData<T>>) => StringGuardParamFn;
/**
* Abstract Guard for preventing unauthorized access to {@link DSpaceObject} pages that require rights for any specific feature in a list
* This guard utilizes a resolver to retrieve the relevant object to check authorizations for
*/
export abstract class DsoPageSomeFeatureGuard<T extends DSpaceObject> extends SomeFeatureAuthorizationGuard {
protected abstract resolver: ResolveFn<RemoteData<DSpaceObject>>;
constructor(protected authorizationService: AuthorizationDataService,
protected router: Router,
protected authService: AuthService) {
super(authorizationService, router, authService);
}
/**
* Check authorization rights for the object resolved using the provided resolver
*/
getObjectUrl(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<string> {
const routeWithObjectID = this.getRouteWithDSOId(route);
return (this.resolver(routeWithObjectID, state) as Observable<RemoteData<T>>).pipe(
getAllSucceededRemoteDataPayload(),
map((dso) => dso.self),
);
}
/**
* Method to resolve (parent) route that contains the UUID of the DSO
* Method to resolve resolve (parent) route that contains the UUID of the DSO
* @param route The current route
*/
protected getRouteWithDSOId(route: ActivatedRouteSnapshot): ActivatedRouteSnapshot {
export const getRouteWithDSOId = (route: ActivatedRouteSnapshot): ActivatedRouteSnapshot => {
let routeWithDSOId = route;
while (hasNoValue(routeWithDSOId.params.id) && hasValue(routeWithDSOId.parent)) {
routeWithDSOId = routeWithDSOId.parent;
}
return routeWithDSOId;
}
}
};
export const defaultDSOGetObjectUrl: DSOGetObjectURlFn = <T extends DSpaceObject>(resolve: ResolveFn<RemoteData<T>>): StringGuardParamFn => {
return (route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<string> => {
const routeWithObjectID = getRouteWithDSOId(route);
return (resolve(routeWithObjectID, state) as Observable<RemoteData<T>>).pipe(
getAllSucceededRemoteDataPayload(),
map((dso) => dso.self),
);
};
};
/**
* Guard for preventing unauthorized access to {@link DSpaceObject} pages that require rights for any specific feature in a list
* This guard utilizes a resolver to retrieve the relevant object to check authorizations for
*/
export const dsoPageSomeFeatureGuard = <T extends DSpaceObject>(
getResolveFn: () => ResolveFn<RemoteData<T>>,
getFeatureIDs: SomeFeatureGuardParamFn,
getObjectUrl: DSOGetObjectURlFn = defaultDSOGetObjectUrl,
getEPersonUuid?: StringGuardParamFn,
): CanActivateFn => someFeatureAuthorizationGuard((route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<FeatureID[]> => getFeatureIDs(route, state), getObjectUrl(getResolveFn()), getEPersonUuid);

View File

@@ -1,35 +1,12 @@
import { Injectable } from '@angular/core';
import {
ActivatedRouteSnapshot,
Router,
RouterStateSnapshot,
} from '@angular/router';
import {
Observable,
of as observableOf,
} from 'rxjs';
import { CanActivateFn } from '@angular/router';
import { of as observableOf } from 'rxjs';
import { AuthService } from '../../../auth/auth.service';
import { AuthorizationDataService } from '../authorization-data.service';
import { FeatureID } from '../feature-id';
import { SingleFeatureAuthorizationGuard } from './single-feature-authorization.guard';
import { singleFeatureAuthorizationGuard } from './single-feature-authorization.guard';
/**
* Prevent unauthorized activating and loading of routes when the current authenticated user doesn't have group
* management rights
*/
@Injectable({
providedIn: 'root',
})
export class GroupAdministratorGuard extends SingleFeatureAuthorizationGuard {
constructor(protected authorizationService: AuthorizationDataService, protected router: Router, protected authService: AuthService) {
super(authorizationService, router, authService);
}
/**
* Check group management rights
*/
getFeatureID(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<FeatureID> {
return observableOf(FeatureID.CanManageGroups);
}
}
export const groupAdministratorGuard: CanActivateFn =
singleFeatureAuthorizationGuard(() => observableOf(FeatureID.CanManageGroups));

View File

@@ -1,7 +1,10 @@
import {
ActivatedRouteSnapshot,
TestBed,
waitForAsync,
} from '@angular/core/testing';
import {
Router,
RouterStateSnapshot,
UrlTree,
} from '@angular/router';
import {
Observable,
@@ -11,37 +14,9 @@ import {
import { AuthService } from '../../../auth/auth.service';
import { AuthorizationDataService } from '../authorization-data.service';
import { FeatureID } from '../feature-id';
import { SingleFeatureAuthorizationGuard } from './single-feature-authorization.guard';
import { singleFeatureAuthorizationGuard } from './single-feature-authorization.guard';
/**
* Test implementation of abstract class SingleFeatureAuthorizationGuard
* Provide the return values of the overwritten getters as constructor arguments
*/
class SingleFeatureAuthorizationGuardImpl extends SingleFeatureAuthorizationGuard {
constructor(protected authorizationService: AuthorizationDataService,
protected router: Router,
protected authService: AuthService,
protected featureId: FeatureID,
protected objectUrl: string,
protected ePersonUuid: string) {
super(authorizationService, router, authService);
}
getFeatureID(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<FeatureID> {
return observableOf(this.featureId);
}
getObjectUrl(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<string> {
return observableOf(this.objectUrl);
}
getEPersonUuid(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<string> {
return observableOf(this.ePersonUuid);
}
}
describe('SingleFeatureAuthorizationGuard', () => {
let guard: SingleFeatureAuthorizationGuard;
describe('singleFeatureAuthorizationGuard', () => {
let authorizationService: AuthorizationDataService;
let router: Router;
let authService: AuthService;
@@ -64,17 +39,36 @@ describe('SingleFeatureAuthorizationGuard', () => {
authService = jasmine.createSpyObj('authService', {
isAuthenticated: observableOf(true),
});
guard = new SingleFeatureAuthorizationGuardImpl(authorizationService, router, authService, featureId, objectUrl, ePersonUuid);
TestBed.configureTestingModule({
providers: [
{ provide: AuthorizationDataService, useValue: authorizationService },
{ provide: Router, useValue: router },
{ provide: AuthService, useValue: authService },
],
});
}
beforeEach(() => {
beforeEach(waitForAsync(() => {
init();
});
}));
describe('canActivate', () => {
it('should call authorizationService.isAuthenticated with the appropriate arguments', () => {
guard.canActivate(undefined, { url: 'current-url' } as any).subscribe();
it('should call authorizationService.isAuthenticated with the appropriate arguments', (done: DoneFn) => {
const result$ = TestBed.runInInjectionContext(() => {
return singleFeatureAuthorizationGuard(
() => observableOf(featureId),
() => observableOf(objectUrl),
() => observableOf(ePersonUuid),
)(undefined, { url: 'current-url' } as any);
}) as Observable<boolean | UrlTree>;
result$.subscribe(() => {
expect(authorizationService.isAuthorized).toHaveBeenCalledWith(featureId, objectUrl, ePersonUuid);
done();
});
});
});
});

View File

@@ -1,31 +1,35 @@
import {
ActivatedRouteSnapshot,
CanActivateFn,
RouterStateSnapshot,
} from '@angular/router';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { FeatureID } from '../feature-id';
import { SomeFeatureAuthorizationGuard } from './some-feature-authorization.guard';
import {
someFeatureAuthorizationGuard,
StringGuardParamFn,
} from './some-feature-authorization.guard';
export declare type SingleFeatureGuardParamFn = (route: ActivatedRouteSnapshot, state: RouterStateSnapshot) => Observable<FeatureID>;
/**
* Abstract Guard for preventing unauthorized activating and loading of routes when a user
* doesn't have authorized rights on a specific feature and/or object.
* Override the desired getters in the parent class for checking specific authorization on a feature and/or object.
* Guard for preventing unauthorized activating and loading of routes when a user doesn't have
* authorized rights on a specific feature and/or object.
*
* @param getFeatureID The feature to check authorization for
* @param getObjectUrl The URL of the object to check if the user has authorized rights for,
* Optional, if not provided, the {@link Site}'s URL will be assumed
* @param getEPersonUuid The UUID of the user to check authorization rights for.
* Optional, if not provided, the authenticated user's UUID will be assumed.
*/
export abstract class SingleFeatureAuthorizationGuard extends SomeFeatureAuthorizationGuard {
/**
* The features to check authorization for
*/
getFeatureIDs(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<FeatureID[]> {
return this.getFeatureID(route, state).pipe(
map((featureID) => [featureID]),
);
}
/**
* The type of feature to check authorization for
* Override this method to define a feature
*/
abstract getFeatureID(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<FeatureID>;
}
export const singleFeatureAuthorizationGuard = (
getFeatureID: SingleFeatureGuardParamFn,
getObjectUrl?: StringGuardParamFn,
getEPersonUuid?: StringGuardParamFn,
): CanActivateFn => someFeatureAuthorizationGuard(
(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<FeatureID[]> => getFeatureID(route, state).pipe(
map((featureID: FeatureID) => [featureID]),
), getObjectUrl, getEPersonUuid);

View File

@@ -1,33 +1,12 @@
import { Injectable } from '@angular/core';
import {
ActivatedRouteSnapshot,
Router,
RouterStateSnapshot,
} from '@angular/router';
import {
Observable,
of as observableOf,
} from 'rxjs';
import { CanActivateFn } from '@angular/router';
import { of as observableOf } from 'rxjs';
import { AuthService } from '../../../auth/auth.service';
import { AuthorizationDataService } from '../authorization-data.service';
import { FeatureID } from '../feature-id';
import { SingleFeatureAuthorizationGuard } from './single-feature-authorization.guard';
import { singleFeatureAuthorizationGuard } from './single-feature-authorization.guard';
/**
* Prevent unauthorized activating and loading of routes when the current authenticated user doesn't have administrator
* rights to the {@link Site}
*/
@Injectable({ providedIn: 'root' })
export class SiteAdministratorGuard extends SingleFeatureAuthorizationGuard {
constructor(protected authorizationService: AuthorizationDataService, protected router: Router, protected authService: AuthService) {
super(authorizationService, router, authService);
}
/**
* Check administrator authorization rights
*/
getFeatureID(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<FeatureID> {
return observableOf(FeatureID.AdministratorOf);
}
}
export const siteAdministratorGuard: CanActivateFn =
singleFeatureAuthorizationGuard(() => observableOf(FeatureID.AdministratorOf));

View File

@@ -1,33 +1,12 @@
import { Injectable } from '@angular/core';
import {
ActivatedRouteSnapshot,
Router,
RouterStateSnapshot,
} from '@angular/router';
import {
Observable,
of as observableOf,
} from 'rxjs';
import { CanActivateFn } from '@angular/router';
import { of as observableOf } from 'rxjs';
import { AuthService } from '../../../auth/auth.service';
import { AuthorizationDataService } from '../authorization-data.service';
import { FeatureID } from '../feature-id';
import { SingleFeatureAuthorizationGuard } from './single-feature-authorization.guard';
import { singleFeatureAuthorizationGuard } from './single-feature-authorization.guard';
/**
* 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 SingleFeatureAuthorizationGuard {
constructor(protected authorizationService: AuthorizationDataService, protected router: Router, protected authService: AuthService) {
super(authorizationService, router, authService);
}
/**
* Check registration authorization rights
*/
getFeatureID(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<FeatureID> {
return observableOf(FeatureID.EPersonRegistration);
}
}
export const siteRegisterGuard: CanActivateFn =
singleFeatureAuthorizationGuard(() => observableOf(FeatureID.EPersonRegistration));

View File

@@ -1,7 +1,10 @@
import {
ActivatedRouteSnapshot,
TestBed,
waitForAsync,
} from '@angular/core/testing';
import {
Router,
RouterStateSnapshot,
UrlTree,
} from '@angular/router';
import {
Observable,
@@ -11,37 +14,9 @@ import {
import { AuthService } from '../../../auth/auth.service';
import { AuthorizationDataService } from '../authorization-data.service';
import { FeatureID } from '../feature-id';
import { SomeFeatureAuthorizationGuard } from './some-feature-authorization.guard';
/**
* Test implementation of abstract class SomeFeatureAuthorizationGuard
* Provide the return values of the overwritten getters as constructor arguments
*/
class SomeFeatureAuthorizationGuardImpl extends SomeFeatureAuthorizationGuard {
constructor(protected authorizationService: AuthorizationDataService,
protected router: Router,
protected authService: AuthService,
protected featureIds: FeatureID[],
protected objectUrl: string,
protected ePersonUuid: string) {
super(authorizationService, router, authService);
}
getFeatureIDs(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<FeatureID[]> {
return observableOf(this.featureIds);
}
getObjectUrl(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<string> {
return observableOf(this.objectUrl);
}
getEPersonUuid(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<string> {
return observableOf(this.ePersonUuid);
}
}
import { someFeatureAuthorizationGuard } from './some-feature-authorization.guard';
describe('SomeFeatureAuthorizationGuard', () => {
let guard: SomeFeatureAuthorizationGuard;
let authorizationService: AuthorizationDataService;
let router: Router;
let authService: AuthService;
@@ -62,18 +37,27 @@ describe('SomeFeatureAuthorizationGuard', () => {
return observableOf(authorizedFeatureIds.indexOf(featureId) > -1);
},
});
router = jasmine.createSpyObj('router', {
parseUrl: {},
});
authService = jasmine.createSpyObj('authService', {
isAuthenticated: observableOf(true),
});
guard = new SomeFeatureAuthorizationGuardImpl(authorizationService, router, authService, featureIds, objectUrl, ePersonUuid);
TestBed.configureTestingModule({
providers: [
{ provide: AuthorizationDataService, useValue: authorizationService },
{ provide: Router, useValue: router },
{ provide: AuthService, useValue: authService },
],
});
}
beforeEach(() => {
beforeEach(waitForAsync(() => {
init();
});
}));
describe('canActivate', () => {
describe('when the user isn\'t authorized', () => {
@@ -82,7 +66,16 @@ describe('SomeFeatureAuthorizationGuard', () => {
});
it('should not return true', (done) => {
guard.canActivate(undefined, { url: 'current-url' } as any).subscribe((result) => {
const result$ = TestBed.runInInjectionContext(() => {
return someFeatureAuthorizationGuard(
() => observableOf(featureIds),
() => observableOf(objectUrl),
() => observableOf(ePersonUuid),
)(undefined, { url: 'current-url' } as any);
}) as Observable<boolean | UrlTree>;
result$.subscribe((result) => {
expect(result).not.toEqual(true);
done();
});
@@ -95,7 +88,16 @@ describe('SomeFeatureAuthorizationGuard', () => {
});
it('should return true', (done) => {
guard.canActivate(undefined, { url: 'current-url' } as any).subscribe((result) => {
const result$ = TestBed.runInInjectionContext(() => {
return someFeatureAuthorizationGuard(
() => observableOf(featureIds),
() => observableOf(objectUrl),
() => observableOf(ePersonUuid),
)(undefined, { url: 'current-url' } as any);
}) as Observable<boolean | UrlTree>;
result$.subscribe((result) => {
expect(result).toEqual(true);
done();
});
@@ -108,7 +110,16 @@ describe('SomeFeatureAuthorizationGuard', () => {
});
it('should return true', (done) => {
guard.canActivate(undefined, { url: 'current-url' } as any).subscribe((result) => {
const result$ = TestBed.runInInjectionContext(() => {
return someFeatureAuthorizationGuard(
() => observableOf(featureIds),
() => observableOf(objectUrl),
() => observableOf(ePersonUuid),
)(undefined, { url: 'current-url' } as any);
}) as Observable<boolean | UrlTree>;
result$.subscribe((result) => {
expect(result).toEqual(true);
done();
});

View File

@@ -1,5 +1,7 @@
import { inject } from '@angular/core';
import {
ActivatedRouteSnapshot,
CanActivateFn,
Router,
RouterStateSnapshot,
UrlTree,
@@ -16,49 +18,39 @@ import { returnForbiddenUrlTreeOrLoginOnAllFalse } from '../../../shared/authori
import { AuthorizationDataService } from '../authorization-data.service';
import { FeatureID } from '../feature-id';
export declare type SomeFeatureGuardParamFn = (route: ActivatedRouteSnapshot, state: RouterStateSnapshot) => Observable<FeatureID[]>;
export declare type StringGuardParamFn = (route: ActivatedRouteSnapshot, state: RouterStateSnapshot) => Observable<string>;
export const defaultStringGuardParamFn = () => observableOf(undefined);
/**
* Abstract Guard for preventing unauthorized activating and loading of routes when a user
* doesn't have authorized rights on any of the specified features and/or object.
* Override the desired getters in the parent class for checking specific authorization on a list of features and/or object.
*/
export abstract class SomeFeatureAuthorizationGuard {
constructor(protected authorizationService: AuthorizationDataService,
protected router: Router,
protected authService: AuthService) {
}
* Guard for preventing unauthorized activating and loading of routes when a user doesn't have
* authorized rights on any of the specified features and/or object.
/**
* True when user has authorization rights for the feature and object provided
* Redirect the user to the unauthorized page when they are not authorized for the given feature
* @param getFeatureIDs The features to check authorization for
* @param getObjectUrl The URL of the object to check if the user has authorized rights for,
* Optional, if not provided, the {@link Site}'s URL will be assumed
* @param getEPersonUuid The UUID of the user to check authorization rights for.
* Optional, if not provided, the authenticated user's UUID will be assumed.
*/
canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<boolean | UrlTree> {
return observableCombineLatest(this.getFeatureIDs(route, state), this.getObjectUrl(route, state), this.getEPersonUuid(route, state)).pipe(
switchMap(([featureIDs, objectUrl, ePersonUuid]) =>
observableCombineLatest(...featureIDs.map((featureID) => this.authorizationService.isAuthorized(featureID, objectUrl, ePersonUuid))),
export const someFeatureAuthorizationGuard = (
getFeatureIDs: SomeFeatureGuardParamFn,
getObjectUrl: StringGuardParamFn = defaultStringGuardParamFn,
getEPersonUuid: StringGuardParamFn = defaultStringGuardParamFn,
): CanActivateFn => {
return (route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<boolean | UrlTree> => {
const authorizationService = inject(AuthorizationDataService);
const router = inject(Router);
const authService = inject(AuthService);
return observableCombineLatest([
getFeatureIDs(route, state),
getObjectUrl(route, state),
getEPersonUuid(route, state),
]).pipe(
switchMap(([featureIDs, objectUrl, ePersonUuid]: [FeatureID[], string, string]) =>
observableCombineLatest(featureIDs.map((featureID) => authorizationService.isAuthorized(featureID, objectUrl, ePersonUuid))),
),
returnForbiddenUrlTreeOrLoginOnAllFalse(this.router, this.authService, state.url),
returnForbiddenUrlTreeOrLoginOnAllFalse(router, authService, state.url),
);
}
};
};
/**
* The features to check authorization for
* Override this method to define a list of features
*/
abstract getFeatureIDs(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<FeatureID[]>;
/**
* 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
*/
getObjectUrl(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<string> {
return observableOf(undefined);
}
/**
* 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.
*/
getEPersonUuid(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<string> {
return observableOf(undefined);
}
}

View File

@@ -1,35 +1,12 @@
import { Injectable } from '@angular/core';
import {
ActivatedRouteSnapshot,
Router,
RouterStateSnapshot,
} from '@angular/router';
import {
Observable,
of as observableOf,
} from 'rxjs';
import { CanActivateFn } from '@angular/router';
import { of as observableOf } from 'rxjs';
import { AuthService } from '../../../auth/auth.service';
import { AuthorizationDataService } from '../authorization-data.service';
import { FeatureID } from '../feature-id';
import { SingleFeatureAuthorizationGuard } from './single-feature-authorization.guard';
import { singleFeatureAuthorizationGuard } from './single-feature-authorization.guard';
/**
* Prevent unauthorized activating and loading of routes when the current authenticated user doesn't have group
* management rights
*/
@Injectable({
providedIn: 'root',
})
export class StatisticsAdministratorGuard extends SingleFeatureAuthorizationGuard {
constructor(protected authorizationService: AuthorizationDataService, protected router: Router, protected authService: AuthService) {
super(authorizationService, router, authService);
}
/**
* Check group management rights
*/
getFeatureID(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<FeatureID> {
return observableOf(FeatureID.CanViewUsageStatistics);
}
}
export const statisticsAdministratorGuard: CanActivateFn =
singleFeatureAuthorizationGuard(() => observableOf(FeatureID.CanViewUsageStatistics));

View File

@@ -61,6 +61,8 @@ export interface VirtualMetadataSource {
export interface RelationshipIdentifiable extends Identifiable {
nameVariant?: string;
originalItem: Item;
originalIsLeft: boolean
relatedItem: Item;
relationship: Relationship;
type: RelationshipType;

View File

@@ -15,6 +15,7 @@ import {
filter,
map,
switchMap,
take,
} from 'rxjs/operators';
import {
@@ -212,8 +213,14 @@ export class ObjectUpdatesService {
* @param url The page's URL for which the changes are saved
* @param field An updated field for the page's object
*/
saveAddFieldUpdate(url: string, field: Identifiable) {
saveAddFieldUpdate(url: string, field: Identifiable): Observable<boolean> {
const update$: Observable<boolean> = this.getFieldUpdatesExclusive(url, [field]).pipe(
filter((fieldUpdates: FieldUpdates) => fieldUpdates[field.uuid].changeType === FieldChangeType.ADD),
take(1),
map(() => true),
);
this.saveFieldUpdate(url, field, FieldChangeType.ADD);
return update$;
}
/**
@@ -221,8 +228,14 @@ export class ObjectUpdatesService {
* @param url The page's URL for which the changes are saved
* @param field An updated field for the page's object
*/
saveRemoveFieldUpdate(url: string, field: Identifiable) {
saveRemoveFieldUpdate(url: string, field: Identifiable): Observable<boolean> {
const update$: Observable<boolean> = this.getFieldUpdatesExclusive(url, [field]).pipe(
filter((fieldUpdates: FieldUpdates) => fieldUpdates[field.uuid].changeType === FieldChangeType.REMOVE),
take(1),
map(() => true),
);
this.saveFieldUpdate(url, field, FieldChangeType.REMOVE);
return update$;
}
/**

View File

@@ -128,6 +128,7 @@ describe('RelationshipDataService', () => {
const itemService = jasmine.createSpyObj('itemService', {
findById: (uuid) => createSuccessfulRemoteDataObject(relatedItems.find((relatedItem) => relatedItem.id === uuid)),
findByHref: createSuccessfulRemoteDataObject$(relatedItems[0]),
getIDHrefObs: (uuid: string) => observableOf(`https://demo.dspace.org/server/api/core/items/${uuid}`),
});
const getRequestEntry$ = (successful: boolean) => {
@@ -244,6 +245,16 @@ describe('RelationshipDataService', () => {
});
});
describe('searchByItemsAndType', () => {
it('should call addDependency for each item to invalidate the request when one of the items is update', () => {
spyOn(service as any, 'addDependency');
service.searchByItemsAndType(relationshipType.id, item.id, relationshipType.leftwardType, ['item-id-1', 'item-id-2']);
expect((service as any).addDependency).toHaveBeenCalledTimes(2);
});
});
describe('resolveMetadataRepresentation', () => {
const parentItem: Item = Object.assign(new Item(), {
id: 'parent-item',

View File

@@ -155,8 +155,11 @@ export class RelationshipDataService extends IdentifiableDataService<Relationshi
* @param id the ID of the relationship to delete
* @param copyVirtualMetadata whether to copy this relationship's virtual metadata to the related Items
* accepted values: none, all, left, right, configured
* @param shouldRefresh refresh the cache for the items in the relationship after creating
* it. Disable this if you want to add relationships in bulk, and
* want to refresh the cachemanually at the end
*/
deleteRelationship(id: string, copyVirtualMetadata: string): Observable<RemoteData<NoContent>> {
deleteRelationship(id: string, copyVirtualMetadata: string, shouldRefresh = true): Observable<RemoteData<NoContent>> {
return this.getRelationshipEndpoint(id).pipe(
isNotEmptyOperator(),
take(1),
@@ -167,7 +170,11 @@ export class RelationshipDataService extends IdentifiableDataService<Relationshi
sendRequest(this.requestService),
switchMap((restRequest: RestRequest) => this.rdbService.buildFromRequestUUID(restRequest.uuid)),
getFirstCompletedRemoteData(),
tap(() => this.refreshRelationshipItemsInCacheByRelationship(id)),
tap(() => {
if (shouldRefresh) {
this.refreshRelationshipItemsInCacheByRelationship(id);
}
}),
);
}
@@ -178,8 +185,11 @@ export class RelationshipDataService extends IdentifiableDataService<Relationshi
* @param item2 The second item of the relationship
* @param leftwardValue The leftward value of the relationship
* @param rightwardValue The rightward value of the relationship
* @param shouldRefresh refresh the cache for the items in the relationship after creating it.
* Disable this if you want to add relationships in bulk, and want to refresh
* the cache manually at the end
*/
addRelationship(typeId: string, item1: Item, item2: Item, leftwardValue?: string, rightwardValue?: string): Observable<RemoteData<Relationship>> {
addRelationship(typeId: string, item1: Item, item2: Item, leftwardValue?: string, rightwardValue?: string, shouldRefresh = true): Observable<RemoteData<Relationship>> {
const options: HttpOptions = Object.create({});
let headers = new HttpHeaders();
headers = headers.append('Content-Type', 'text/uri-list');
@@ -194,8 +204,12 @@ export class RelationshipDataService extends IdentifiableDataService<Relationshi
sendRequest(this.requestService),
switchMap((restRequest: RestRequest) => this.rdbService.buildFromRequestUUID(restRequest.uuid)),
getFirstCompletedRemoteData(),
tap(() => this.refreshRelationshipItemsInCache(item1)),
tap(() => this.refreshRelationshipItemsInCache(item2)),
tap(() => {
if (shouldRefresh) {
this.refreshRelationshipItemsInCache(item1);
this.refreshRelationshipItemsInCache(item2);
}
}),
) as Observable<RemoteData<Relationship>>;
}
@@ -223,7 +237,7 @@ export class RelationshipDataService extends IdentifiableDataService<Relationshi
* Method to remove an item that's part of a relationship from the cache
* @param item The item to remove from the cache
*/
public refreshRelationshipItemsInCache(item) {
public refreshRelationshipItemsInCache(item: Item): void {
this.objectCache.remove(item._links.self.href);
this.requestService.removeByHrefSubstring(item.uuid);
observableCombineLatest([
@@ -336,7 +350,19 @@ export class RelationshipDataService extends IdentifiableDataService<Relationshi
} else {
findListOptions.searchParams = searchParams;
}
return this.searchBy('byLabel', findListOptions, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow);
// always set reRequestOnStale to false here, so it doesn't happen automatically in BaseDataService
const result$ = this.searchBy('byLabel', findListOptions, useCachedVersionIfAvailable, false, ...linksToFollow);
// add this result as a dependency of the item, meaning that if the item is invalided, this
// result will be as well
this.addDependency(result$, item._links.self.href);
// do the reRequestOnStale call here, to ensure any re-requests also get added as dependencies
return result$.pipe(
this.reRequestStaleRemoteData(reRequestOnStale, () =>
this.getItemRelationshipsByLabel(item, label, options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow)),
);
}
/**
@@ -548,13 +574,18 @@ export class RelationshipDataService extends IdentifiableDataService<Relationshi
);
});
return this.searchBy(
const searchRD$: Observable<RemoteData<PaginatedList<Relationship>>> = this.searchBy(
'byItemsAndType',
{
searchParams: searchParams,
},
) as Observable<RemoteData<PaginatedList<Relationship>>>;
arrayOfItemIds.forEach((itemId: string) => {
this.addDependency(searchRD$, this.itemService.getIDHrefObs(encodeURIComponent(itemId)));
});
return searchRD$;
}
/**

View File

@@ -69,6 +69,10 @@ describe('VersionHistoryDataService', () => {
},
},
});
const version1WithDraft = Object.assign(new Version(), {
...version1,
versionhistory: createSuccessfulRemoteDataObject$(versionHistoryDraft),
});
const versions = [version1, version2];
versionHistory.versions = createSuccessfulRemoteDataObject$(createPaginatedList(versions));
const item1 = Object.assign(new Item(), {
@@ -190,21 +194,18 @@ describe('VersionHistoryDataService', () => {
});
describe('hasDraftVersion$', () => {
beforeEach(waitForAsync(() => {
versionService.findByHref.and.returnValue(createSuccessfulRemoteDataObject$<Version>(version1));
}));
it('should return false if draftVersion is false', fakeAsync(() => {
versionService.getHistoryFromVersion.and.returnValue(of(versionHistory));
versionService.findByHref.and.returnValue(createSuccessfulRemoteDataObject$<Version>(version1));
service.hasDraftVersion$('href').subscribe((res) => {
expect(res).toBeFalse();
});
}));
it('should return true if draftVersion is true', fakeAsync(() => {
versionService.getHistoryFromVersion.and.returnValue(of(versionHistoryDraft));
versionService.findByHref.and.returnValue(createSuccessfulRemoteDataObject$<Version>(version1WithDraft));
service.hasDraftVersion$('href').subscribe((res) => {
expect(res).toBeTrue();
});
}));
});
});

View File

@@ -1,17 +1,22 @@
import { HttpHeaders } from '@angular/common/http';
import { Injectable } from '@angular/core';
import {
combineLatest,
Observable,
of,
of as observableOf,
} from 'rxjs';
import {
filter,
find,
map,
switchMap,
take,
} from 'rxjs/operators';
import { hasValueOperator } from '../../shared/empty.util';
import {
hasValue,
hasValueOperator,
} from '../../shared/empty.util';
import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model';
import { PaginatedSearchOptions } from '../../shared/search/models/paginated-search-options.model';
import {
@@ -29,7 +34,6 @@ import {
getFirstSucceededRemoteDataPayload,
getRemoteDataPayload,
} from '../shared/operators';
import { sendRequest } from '../shared/request.operators';
import { Version } from '../shared/version.model';
import { VersionHistory } from '../shared/version-history.model';
import { IdentifiableDataService } from './base/identifiable-data.service';
@@ -38,7 +42,6 @@ import { PaginatedList } from './paginated-list.model';
import { RemoteData } from './remote-data';
import { PostRequest } from './request.models';
import { RequestService } from './request.service';
import { RestRequest } from './rest-request.model';
import { VersionDataService } from './version-data.service';
/**
@@ -100,19 +103,31 @@ export class VersionHistoryDataService extends IdentifiableDataService<VersionHi
* @param summary the summary of the new version
*/
createVersion(itemHref: string, summary: string): Observable<RemoteData<Version>> {
const requestId = this.requestService.generateRequestId();
const requestOptions: HttpOptions = Object.create({});
let requestHeaders = new HttpHeaders();
requestHeaders = requestHeaders.append('Content-Type', 'text/uri-list');
requestOptions.headers = requestHeaders;
return this.halService.getEndpoint(this.versionsEndpoint).pipe(
this.halService.getEndpoint(this.versionsEndpoint).pipe(
take(1),
map((endpointUrl: string) => (summary?.length > 0) ? `${endpointUrl}?summary=${summary}` : `${endpointUrl}`),
map((endpointURL: string) => new PostRequest(this.requestService.generateRequestId(), endpointURL, itemHref, requestOptions)),
sendRequest(this.requestService),
switchMap((restRequest: RestRequest) => this.rdbService.buildFromRequestUUID(restRequest.uuid)),
find((href: string) => hasValue(href)),
).subscribe((href) => {
const request = new PostRequest(requestId, href, itemHref, requestOptions);
if (hasValue(this.responseMsToLive)) {
request.responseMsToLive = this.responseMsToLive;
}
this.requestService.send(request);
});
return this.rdbService.buildFromRequestUUIDAndAwait<Version>(requestId, (versionRD) => combineLatest([
this.requestService.setStaleByHrefSubstring(versionRD.payload._links.self.href),
this.requestService.setStaleByHrefSubstring(versionRD.payload._links.versionhistory.href),
])).pipe(
getFirstCompletedRemoteData(),
) as Observable<RemoteData<Version>>;
);
}
/**
@@ -151,7 +166,7 @@ export class VersionHistoryDataService extends IdentifiableDataService<VersionHi
switchMap((res) => res.versionhistory),
getFirstSucceededRemoteDataPayload(),
switchMap((versionHistoryRD) => this.getLatestVersionFromHistory$(versionHistoryRD)),
) : of(null);
) : observableOf(null);
}
/**
@@ -162,8 +177,8 @@ export class VersionHistoryDataService extends IdentifiableDataService<VersionHi
isLatest$(version: Version): Observable<boolean> {
return version ? this.getLatestVersion$(version).pipe(
take(1),
switchMap((latestVersion) => of(version.version === latestVersion.version)),
) : of(null);
switchMap((latestVersion) => observableOf(version.version === latestVersion.version)),
) : observableOf(null);
}
/**
@@ -172,17 +187,22 @@ export class VersionHistoryDataService extends IdentifiableDataService<VersionHi
* @returns `true` if a workspace item exists, `false` otherwise, or `null` if a version history does not exist
*/
hasDraftVersion$(versionHref: string): Observable<boolean> {
return this.versionDataService.findByHref(versionHref, true, true, followLink('versionhistory')).pipe(
return this.versionDataService.findByHref(versionHref, false, true, followLink('versionhistory')).pipe(
getFirstCompletedRemoteData(),
switchMap((res) => {
if (res.hasSucceeded && !res.hasNoContent) {
return of(res).pipe(
getFirstSucceededRemoteDataPayload(),
switchMap((version) => this.versionDataService.getHistoryFromVersion(version)),
map((versionHistory) => versionHistory ? versionHistory.draftVersion : false),
switchMap((versionRD: RemoteData<Version>) => {
if (versionRD.hasSucceeded && !versionRD.hasNoContent) {
return versionRD.payload.versionhistory.pipe(
getFirstCompletedRemoteData(),
map((versionHistoryRD: RemoteData<VersionHistory>) => {
if (versionHistoryRD.hasSucceeded && !versionHistoryRD.hasNoContent) {
return versionHistoryRD.payload.draftVersion;
} else {
return false;
}
}),
);
} else {
return of(false);
return observableOf(false);
}
}),
);

View File

@@ -1,45 +0,0 @@
import {
ActivatedRouteSnapshot,
Router,
RouterStateSnapshot,
UrlTree,
} from '@angular/router';
import {
Observable,
of as observableOf,
} from 'rxjs';
import { environment } from '../../../environments/environment';
import { returnEndUserAgreementUrlTreeOnFalse } from '../shared/authorized.operators';
/**
* An abstract guard for redirecting users to the user agreement page if a certain condition is met
* That condition is defined by abstract method hasAccepted
*/
export abstract class AbstractEndUserAgreementGuard {
constructor(protected router: Router) {
}
/**
* True when the user agreement has been accepted
* The user will be redirected to the End User Agreement page if they haven't accepted it before
* A redirect URL will be provided with the navigation so the component can redirect the user back to the blocked route
* when they're finished accepting the agreement
*/
canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<boolean | UrlTree> {
if (!environment.info.enableEndUserAgreement) {
return observableOf(true);
}
return this.hasAccepted().pipe(
returnEndUserAgreementUrlTreeOnFalse(this.router, state.url),
);
}
/**
* This abstract method determines how the User Agreement has to be accepted before the user is allowed to visit
* the desired route
*/
abstract hasAccepted(): Observable<boolean>;
}

View File

@@ -1,13 +1,14 @@
import { TestBed } from '@angular/core/testing';
import {
Router,
UrlTree,
} from '@angular/router';
import { Observable } from 'rxjs';
import { EndUserAgreementService } from './end-user-agreement.service';
import { EndUserAgreementCookieGuard } from './end-user-agreement-cookie.guard';
import { endUserAgreementCookieGuard } from './end-user-agreement-cookie.guard';
describe('EndUserAgreementCookieGuard', () => {
let guard: EndUserAgreementCookieGuard;
describe('endUserAgreementCookieGuard', () => {
let endUserAgreementService: EndUserAgreementService;
let router: Router;
@@ -21,14 +22,22 @@ describe('EndUserAgreementCookieGuard', () => {
parseUrl: new UrlTree(),
createUrlTree: new UrlTree(),
});
guard = new EndUserAgreementCookieGuard(endUserAgreementService, router);
TestBed.configureTestingModule({
providers: [
{ provide: Router, useValue: router },
{ provide: EndUserAgreementService, useValue: endUserAgreementService },
],
});
});
describe('canActivate', () => {
describe('when the cookie has been accepted', () => {
it('should return true', (done) => {
guard.canActivate(undefined, { url: Object.assign({ url: 'redirect' }) } as any).subscribe((result) => {
const result$ = TestBed.runInInjectionContext(() => {
return endUserAgreementCookieGuard(undefined, { url: Object.assign({ url: 'redirect' }) } as any);
}) as Observable<boolean | UrlTree>;
result$.subscribe((result) => {
expect(result).toEqual(true);
done();
});
@@ -41,7 +50,11 @@ describe('EndUserAgreementCookieGuard', () => {
});
it('should return a UrlTree', (done) => {
guard.canActivate(undefined, Object.assign({ url: 'redirect' })).subscribe((result) => {
const result$ = TestBed.runInInjectionContext(() => {
return endUserAgreementCookieGuard(undefined, { url: Object.assign({ url: 'redirect' }) } as any);
}) as Observable<boolean | UrlTree>;
result$.subscribe((result) => {
expect(result).toEqual(jasmine.any(UrlTree));
done();
});

View File

@@ -1,29 +1,19 @@
import { Injectable } from '@angular/core';
import { Router } from '@angular/router';
import {
Observable,
of as observableOf,
} from 'rxjs';
import { inject } from '@angular/core';
import { CanActivateFn } from '@angular/router';
import { of as observableOf } from 'rxjs';
import { AbstractEndUserAgreementGuard } from './abstract-end-user-agreement.guard';
import { endUserAgreementGuard } from './end-user-agreement.guard';
import { EndUserAgreementService } from './end-user-agreement.service';
/**
* A guard redirecting users to the end agreement page when the user agreement cookie hasn't been accepted
* Guard for preventing unauthorized access to certain pages
* requiring the end user agreement to have been accepted in a cookie
*/
@Injectable({ providedIn: 'root' })
export class EndUserAgreementCookieGuard extends AbstractEndUserAgreementGuard {
constructor(protected endUserAgreementService: EndUserAgreementService,
protected router: Router) {
super(router);
}
/**
* True when the user agreement cookie has been accepted
*/
hasAccepted(): Observable<boolean> {
return observableOf(this.endUserAgreementService.isCookieAccepted());
}
}
export const endUserAgreementCookieGuard: CanActivateFn =
endUserAgreementGuard(
() => {
const endUserAgreementService = inject(EndUserAgreementService);
return observableOf(endUserAgreementService.isCookieAccepted());
},
);

View File

@@ -1,16 +1,18 @@
import { TestBed } from '@angular/core/testing';
import {
Router,
UrlTree,
} from '@angular/router';
import { of as observableOf } from 'rxjs';
import {
Observable,
of as observableOf,
} from 'rxjs';
import { environment } from '../../../environments/environment.test';
import { EndUserAgreementService } from './end-user-agreement.service';
import { EndUserAgreementCurrentUserGuard } from './end-user-agreement-current-user.guard';
describe('EndUserAgreementGuard', () => {
let guard: EndUserAgreementCurrentUserGuard;
import { endUserAgreementCurrentUserGuard } from './end-user-agreement-current-user.guard';
describe('endUserAgreementGuard', () => {
let endUserAgreementService: EndUserAgreementService;
let router: Router;
@@ -18,19 +20,30 @@ describe('EndUserAgreementGuard', () => {
endUserAgreementService = jasmine.createSpyObj('endUserAgreementService', {
hasCurrentUserAcceptedAgreement: observableOf(true),
});
router = jasmine.createSpyObj('router', {
navigateByUrl: {},
parseUrl: new UrlTree(),
createUrlTree: new UrlTree(),
});
guard = new EndUserAgreementCurrentUserGuard(endUserAgreementService, router);
TestBed.configureTestingModule({
providers: [
{ provide: Router, useValue: router },
{ provide: EndUserAgreementService, useValue: endUserAgreementService },
],
});
});
describe('canActivate', () => {
describe('when the user has accepted the agreement', () => {
it('should return true', (done) => {
guard.canActivate(undefined, Object.assign({ url: 'redirect' })).subscribe((result) => {
const result$ = TestBed.runInInjectionContext(() => {
return endUserAgreementCurrentUserGuard(undefined, Object.assign({ url: 'redirect' }));
}) as Observable<boolean | UrlTree>;
result$.subscribe((result) => {
expect(result).toEqual(true);
done();
});
@@ -43,7 +56,11 @@ describe('EndUserAgreementGuard', () => {
});
it('should return a UrlTree', (done) => {
guard.canActivate(undefined, Object.assign({ url: 'redirect' })).subscribe((result) => {
const result$ = TestBed.runInInjectionContext(() => {
return endUserAgreementCurrentUserGuard(undefined, Object.assign({ url: 'redirect' }));
}) as Observable<boolean | UrlTree>;
result$.subscribe((result) => {
expect(result).toEqual(jasmine.any(UrlTree));
done();
});
@@ -53,7 +70,12 @@ describe('EndUserAgreementGuard', () => {
describe('when the end user agreement is disabled', () => {
it('should return true', (done) => {
environment.info.enableEndUserAgreement = false;
guard.canActivate(undefined, Object.assign({ url: 'redirect' })).subscribe((result) => {
const result$ = TestBed.runInInjectionContext(() => {
return endUserAgreementCurrentUserGuard(undefined, Object.assign({ url: 'redirect' }));
}) as Observable<boolean | UrlTree>;
result$.subscribe((result) => {
expect(result).toEqual(true);
done();
});
@@ -61,7 +83,11 @@ describe('EndUserAgreementGuard', () => {
it('should not resolve to the end user agreement page', (done) => {
environment.info.enableEndUserAgreement = false;
guard.canActivate(undefined, Object.assign({ url: 'redirect' })).subscribe((result) => {
const result$ = TestBed.runInInjectionContext(() => {
return endUserAgreementCurrentUserGuard(undefined, Object.assign({ url: 'redirect' }));
}) as Observable<boolean | UrlTree>;
result$.subscribe((result) => {
expect(router.navigateByUrl).not.toHaveBeenCalled();
done();
});

View File

@@ -1,34 +1,25 @@
import { Injectable } from '@angular/core';
import { Router } from '@angular/router';
import {
Observable,
of as observableOf,
} from 'rxjs';
import { inject } from '@angular/core';
import { CanActivateFn } from '@angular/router';
import { of as observableOf } from 'rxjs';
import { environment } from '../../../environments/environment';
import { AbstractEndUserAgreementGuard } from './abstract-end-user-agreement.guard';
import { endUserAgreementGuard } from './end-user-agreement.guard';
import { EndUserAgreementService } from './end-user-agreement.service';
/**
* A guard redirecting logged in users to the end agreement page when they haven't accepted the latest user agreement
*/
@Injectable({ providedIn: 'root' })
export class EndUserAgreementCurrentUserGuard extends AbstractEndUserAgreementGuard {
* Guard for preventing unauthorized access to certain pages
* requiring the end user agreement to have been accepted by the current user
constructor(protected endUserAgreementService: EndUserAgreementService,
protected router: Router) {
super(router);
}
/**
* True when the currently logged in user has accepted the agreements or when the user is not currently authenticated
*/
hasAccepted(): Observable<boolean> {
export const endUserAgreementCurrentUserGuard: CanActivateFn =
endUserAgreementGuard(
() => {
const endUserAgreementService = inject(EndUserAgreementService);
if (!environment.info.enableEndUserAgreement) {
return observableOf(true);
}
return this.endUserAgreementService.hasCurrentUserAcceptedAgreement(true);
}
}
return endUserAgreementService.hasCurrentUserAcceptedAgreement(true);
},
);

View File

@@ -0,0 +1,34 @@
import { inject } from '@angular/core';
import {
ActivatedRouteSnapshot,
CanActivateFn,
Router,
RouterStateSnapshot,
UrlTree,
} from '@angular/router';
import {
Observable,
of as observableOf,
} from 'rxjs';
import { environment } from '../../../environments/environment';
import { returnEndUserAgreementUrlTreeOnFalse } from '../shared/authorized.operators';
export declare type HasAcceptedGuardParamFn = () => Observable<boolean>;
/**
* Guard for preventing activating when the user has not accepted the EndUserAgreement
* @param hasAccepted Function determining if the EndUserAgreement has been accepted
*/
export const endUserAgreementGuard = (
hasAccepted: HasAcceptedGuardParamFn,
): CanActivateFn => {
return (route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<boolean | UrlTree> => {
const router = inject(Router);
if (!environment.info.enableEndUserAgreement) {
return observableOf(true);
}
return hasAccepted().pipe(
returnEndUserAgreementUrlTreeOnFalse(router, state.url),
);
};
};

View File

@@ -107,13 +107,17 @@ export class EPersonDataService extends IdentifiableDataService<EPerson> impleme
* @param scope Scope of the EPeople search, default byMetadata
* @param query Query of search
* @param options Options of search request
* @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's
* no valid cached version. Defaults to true
* @param reRequestOnStale Whether or not the request should automatically be re-
* requested after the response becomes stale
*/
public searchByScope(scope: string, query: string, options: FindListOptions = {}, useCachedVersionIfAvailable?: boolean): Observable<RemoteData<PaginatedList<EPerson>>> {
public searchByScope(scope: string, query: string, options: FindListOptions = {}, useCachedVersionIfAvailable?: boolean, reRequestOnStale = true): Observable<RemoteData<PaginatedList<EPerson>>> {
switch (scope) {
case 'metadata':
return this.getEpeopleByMetadata(query.trim(), options, useCachedVersionIfAvailable);
return this.getEpeopleByMetadata(query.trim(), options, useCachedVersionIfAvailable, reRequestOnStale);
case 'email':
return this.getEPersonByEmail(query.trim()).pipe(
return this.getEPersonByEmail(query.trim(), useCachedVersionIfAvailable, reRequestOnStale).pipe(
map((rd: RemoteData<EPerson | NoContent>) => {
if (rd.hasSucceeded) {
// Turn the single EPerson or NoContent in to a PaginatedList<EPerson>
@@ -145,7 +149,7 @@ export class EPersonDataService extends IdentifiableDataService<EPerson> impleme
}),
);
default:
return this.getEpeopleByMetadata(query.trim(), options, useCachedVersionIfAvailable);
return this.getEpeopleByMetadata(query.trim(), options, useCachedVersionIfAvailable, reRequestOnStale);
}
}

View File

@@ -1,37 +1,11 @@
import { Injectable } from '@angular/core';
import {
ActivatedRouteSnapshot,
Router,
RouterStateSnapshot,
} from '@angular/router';
import {
Observable,
of,
} from 'rxjs';
import { CanActivateFn } from '@angular/router';
import { of as observableOf } from 'rxjs';
import { AuthService } from '../auth/auth.service';
import { AuthorizationDataService } from '../data/feature-authorization/authorization-data.service';
import { SingleFeatureAuthorizationGuard } from '../data/feature-authorization/feature-authorization-guard/single-feature-authorization.guard';
import { singleFeatureAuthorizationGuard } from '../data/feature-authorization/feature-authorization-guard/single-feature-authorization.guard';
import { FeatureID } from '../data/feature-authorization/feature-id';
@Injectable({
providedIn: 'root',
})
/**
* Guard that checks if the forgot-password feature is enabled
*/
export class ForgotPasswordCheckGuard extends SingleFeatureAuthorizationGuard {
constructor(
protected readonly authorizationService: AuthorizationDataService,
protected readonly router: Router,
protected readonly authService: AuthService,
) {
super(authorizationService, router, authService);
}
getFeatureID(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<FeatureID> {
return of(FeatureID.EPersonForgotPassword);
}
}
export const forgotPasswordCheckGuard: CanActivateFn =
singleFeatureAuthorizationGuard(() => observableOf(FeatureID.EPersonForgotPassword));

View File

@@ -29,14 +29,12 @@ import { HALEndpointService } from '../shared/hal-endpoint.service';
import { NoContent } from '../shared/NoContent.model';
import { getFirstCompletedRemoteData } from '../shared/operators';
import { WorkflowItem } from './models/workflowitem.model';
import { WorkspaceItem } from './models/workspaceitem.model';
/**
* A service that provides methods to make REST requests with workflow items endpoint.
*/
@Injectable({ providedIn: 'root' })
export class WorkflowItemDataService extends IdentifiableDataService<WorkflowItem> implements SearchData<WorkflowItem>, DeleteData<WorkflowItem> {
protected linkPath = 'workflowitems';
protected searchByItemLinkPath = 'item';
protected responseMsToLive = 10 * 1000;
@@ -50,7 +48,7 @@ export class WorkflowItemDataService extends IdentifiableDataService<WorkflowIte
protected halService: HALEndpointService,
protected notificationsService: NotificationsService,
) {
super('workspaceitems', requestService, rdbService, objectCache, halService);
super('workflowitems', requestService, rdbService, objectCache, halService);
this.searchData = new SearchDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive);
this.deleteData = new DeleteDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, notificationsService, this.responseMsToLive, this.constructIdEndpoint);
@@ -113,7 +111,7 @@ export class WorkflowItemDataService extends IdentifiableDataService<WorkflowIte
* @param options The {@link FindListOptions} object
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved
*/
public findByItem(uuid: string, useCachedVersionIfAvailable = false, reRequestOnStale = true, options: FindListOptions = {}, ...linksToFollow: FollowLinkConfig<WorkspaceItem>[]): Observable<RemoteData<WorkspaceItem>> {
public findByItem(uuid: string, useCachedVersionIfAvailable = false, reRequestOnStale = true, options: FindListOptions = {}, ...linksToFollow: FollowLinkConfig<WorkflowItem>[]): Observable<RemoteData<WorkflowItem>> {
const findListOptions = new FindListOptions();
findListOptions.searchParams = [new RequestParam('uuid', uuid)];
const href$ = this.searchData.getSearchByHref(this.searchByItemLinkPath, findListOptions, ...linksToFollow);
@@ -134,7 +132,7 @@ export class WorkflowItemDataService extends IdentifiableDataService<WorkflowIte
* @return {Observable<RemoteData<PaginatedList<T>>}
* Return an observable that emits response from the server
*/
public searchBy(searchMethod: string, options?: FindListOptions, useCachedVersionIfAvailable?: boolean, reRequestOnStale?: boolean, ...linksToFollow: FollowLinkConfig<WorkspaceItem>[]): Observable<RemoteData<PaginatedList<WorkspaceItem>>> {
public searchBy(searchMethod: string, options?: FindListOptions, useCachedVersionIfAvailable?: boolean, reRequestOnStale?: boolean, ...linksToFollow: FollowLinkConfig<WorkflowItem>[]): Observable<RemoteData<PaginatedList<WorkflowItem>>> {
return this.searchData.searchBy(searchMethod, options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow);
}

View File

@@ -2,13 +2,18 @@ import {
HttpClient,
HttpHeaders,
} from '@angular/common/http';
import { waitForAsync } from '@angular/core/testing';
import { Store } from '@ngrx/store';
import {
cold,
getTestScheduler,
hot,
} from 'jasmine-marbles';
import { of as observableOf } from 'rxjs';
import {
of as observableOf,
of,
} from 'rxjs';
import { map } from 'rxjs/operators';
import { TestScheduler } from 'rxjs/testing';
import { getMockHrefOnlyDataService } from '../../shared/mocks/href-only-data.service.mock';
@@ -151,12 +156,17 @@ describe('WorkspaceitemDataService test', () => {
});
describe('findByItem', () => {
it('should proxy the call to UpdateDataServiceImpl.findByHref', () => {
it('should proxy the call to UpdateDataServiceImpl.findByHref', waitForAsync(() => {
scheduler.schedule(() => service.findByItem('1234-1234', true, true, pageInfo));
scheduler.flush();
const searchUrl = service.getIDHref('item', [new RequestParam('uuid', '1234-1234')]);
expect((service as any).findByHref).toHaveBeenCalledWith(searchUrl, true, true);
const searchUrl$ =
of('https://rest.api/rest/api/submission/workspaceitems/search/item')
.pipe(map(href => service.buildHrefFromFindOptions(href, { searchParams: [new RequestParam('uuid', '1234-1234')] }, [])));
searchUrl$.subscribe((url) => {
expect(url).toEqual('https://rest.api/rest/api/submission/workspaceitems/search/item?uuid=1234-1234');
});
expect((service as any).findByHref).toHaveBeenCalled();
}));
it('should return a RemoteData<WorkspaceItem> for the search', () => {
const result = service.findByItem('1234-1234', true, true, pageInfo);

View File

@@ -45,7 +45,7 @@ export class WorkspaceitemDataService extends IdentifiableDataService<WorkspaceI
protected linkPath = 'workspaceitems';
protected searchByItemLinkPath = 'item';
private deleteData: DeleteData<WorkspaceItem>;
private searchData: SearchData<WorkspaceItem>;
private searchData: SearchDataImpl<WorkspaceItem>;
constructor(
protected comparator: DSOChangeAnalyzer<WorkspaceItem>,
@@ -91,7 +91,7 @@ export class WorkspaceitemDataService extends IdentifiableDataService<WorkspaceI
public findByItem(uuid: string, useCachedVersionIfAvailable = false, reRequestOnStale = true, options: FindListOptions = {}, ...linksToFollow: FollowLinkConfig<WorkspaceItem>[]): Observable<RemoteData<WorkspaceItem>> {
const findListOptions = new FindListOptions();
findListOptions.searchParams = [new RequestParam('uuid', uuid)];
const href$ = this.getIDHref(this.searchByItemLinkPath, findListOptions, ...linksToFollow);
const href$ = this.searchData.getSearchByHref(this.searchByItemLinkPath, findListOptions, ...linksToFollow);
return this.findByHref(href$, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow);
}

View File

@@ -84,7 +84,7 @@
</div>
<div *ngIf="coarLdnEnabled$ | async" class="notify-enabled text-white">
<a class="coar-notify-support-route" routerLink="info/coar-notify-support">
<img class="n-coar" src="assets/images/n-coar.png" [attr.alt]="'menu.header.image.logo' | translate" />
<img class="n-coar" src="assets/images/n-coar.svg" [attr.alt]="'menu.header.image.logo' | translate" />
{{ 'footer.link.coar-notify-support' | translate }}
</a>
</div>

View File

@@ -1,10 +1,7 @@
import {
mapToCanActivate,
Route,
} from '@angular/router';
import { Route } from '@angular/router';
import { i18nBreadcrumbResolver } from '../core/breadcrumbs/i18n-breadcrumb.resolver';
import { SiteAdministratorGuard } from '../core/data/feature-authorization/feature-authorization-guard/site-administrator.guard';
import { siteAdministratorGuard } from '../core/data/feature-authorization/feature-authorization-guard/site-administrator.guard';
import { HealthPageComponent } from './health-page.component';
export const ROUTES: Route[] = [
@@ -15,7 +12,7 @@ export const ROUTES: Route[] = [
breadcrumbKey: 'health',
title: 'health-page.title',
},
canActivate: mapToCanActivate([SiteAdministratorGuard]),
canActivate: [siteAdministratorGuard],
component: HealthPageComponent,
},
];

View File

@@ -1,18 +1,13 @@
<div class="container">
<head>
<title>{{ 'coar-notify-support.title' | translate }}</title>
</head>
<body>
<h1>{{ 'coar-notify-support.title' | translate }}</h1>
<p [innerHTML]="('coar-notify-support-title.content' | translate)"></p>
<p [innerHTML]="'coar-notify-support-title.content' | translate"></p>
<h2>{{ 'coar-notify-support.ldn-inbox.title' | translate }}</h2>
<p [innerHTML]="('coar-notify-support.ldn-inbox.content' | translate).replace('{ldnInboxUrl}', generateCoarRestApiLinksHTML() | async)"></p>
<p [innerHTML]="'coar-notify-support.ldn-inbox.content' | translate:{ ldnInboxUrl: coarRestApiUrls$ | async }"></p>
<h2>{{ 'coar-notify-support.message-moderation.title' | translate }}</h2>
<p>
{{ 'coar-notify-support.message-moderation.content' | translate }}
<a routerLink="/info/feedback" >{{ 'coar-notify-support.message-moderation.feedback-form' | translate }}</a>
<a routerLink="/info/feedback">{{ 'coar-notify-support.message-moderation.feedback-form' | translate }}</a>
</p>
</body>
</div>

View File

@@ -16,7 +16,9 @@ describe('NotifyInfoComponent', () => {
let notifyInfoServiceSpy: any;
beforeEach(async () => {
notifyInfoServiceSpy = jasmine.createSpyObj('NotifyInfoService', ['getCoarLdnLocalInboxUrls']);
notifyInfoServiceSpy = jasmine.createSpyObj('NotifyInfoService', {
getCoarLdnLocalInboxUrls: of([]),
});
await TestBed.configureTestingModule({
imports: [TranslateModule.forRoot(), NotifyInfoComponent],
@@ -31,8 +33,7 @@ describe('NotifyInfoComponent', () => {
beforeEach(() => {
fixture = TestBed.createComponent(NotifyInfoComponent);
component = fixture.componentInstance;
component.coarRestApiUrl = of([]);
spyOn(component, 'generateCoarRestApiLinksHTML').and.returnValue(of(''));
component.coarRestApiUrls$ = of('');
fixture.detectChanges();
});

View File

@@ -8,7 +8,6 @@ import { TranslateModule } from '@ngx-translate/core';
import {
map,
Observable,
of,
} from 'rxjs';
import { NotifyInfoService } from '../../core/coar-notify/notify-info/notify-info.service';
@@ -31,26 +30,17 @@ export class NotifyInfoComponent implements OnInit {
/**
* Observable containing the COAR REST INBOX API URLs.
*/
coarRestApiUrl: Observable<string[]> = of([]);
coarRestApiUrls$: Observable<string>;
constructor(private notifyInfoService: NotifyInfoService) {}
constructor(
protected notifyInfoService: NotifyInfoService,
) {
}
ngOnInit() {
this.coarRestApiUrl = this.notifyInfoService.getCoarLdnLocalInboxUrls();
}
/**
* Generates HTML code for COAR REST API links.
* @returns An Observable that emits the generated HTML code.
*/
generateCoarRestApiLinksHTML() {
return this.coarRestApiUrl.pipe(
// transform the data into HTML
map((urls) => {
return urls.map(url => `
<code><a href="${url}" target="_blank"><span class="api-url">${url}</span></a></code>
`).join(',');
}),
this.coarRestApiUrls$ = this.notifyInfoService.getCoarLdnLocalInboxUrls().pipe(
map((urls: string[]) => urls.map((url: string) => `<a href="${url}" target="_blank">${url}</a>`).join(', ')),
);
}
}

View File

@@ -55,6 +55,10 @@ export class AbstractItemUpdateComponent extends AbstractTrackableComponent impl
*/
updates$: Observable<FieldUpdates>;
hasChanges$: Observable<boolean>;
isReinstatable$: Observable<boolean>;
/**
* Route to the item's page
*/
@@ -101,10 +105,9 @@ export class AbstractItemUpdateComponent extends AbstractTrackableComponent impl
}
this.discardTimeOut = environment.item.edit.undoTimeout;
this.url = this.router.url;
if (this.url.indexOf('?') > 0) {
this.url = this.url.substr(0, this.url.indexOf('?'));
}
this.url = this.router.url.split('?')[0];
this.hasChanges$ = this.hasChanges();
this.isReinstatable$ = this.isReinstatable();
this.hasChanges().pipe(first()).subscribe((hasChanges) => {
if (!hasChanges) {
this.initializeOriginalFields();

View File

@@ -1,7 +1,4 @@
import {
mapToCanActivate,
Route,
} from '@angular/router';
import { Route } from '@angular/router';
import { i18nBreadcrumbResolver } from '../../core/breadcrumbs/i18n-breadcrumb.resolver';
import { ThemedDsoEditMetadataComponent } from '../../dso-shared/dso-edit-metadata/themed-dso-edit-metadata.component';
@@ -27,17 +24,21 @@ import { ItemCollectionMapperComponent } from './item-collection-mapper/item-col
import { ItemCurateComponent } from './item-curate/item-curate.component';
import { ItemDeleteComponent } from './item-delete/item-delete.component';
import { ItemMoveComponent } from './item-move/item-move.component';
import { ItemPageAccessControlGuard } from './item-page-access-control.guard';
import { ItemPageBitstreamsGuard } from './item-page-bitstreams.guard';
import { ItemPageCollectionMapperGuard } from './item-page-collection-mapper.guard';
import { ItemPageCurateGuard } from './item-page-curate.guard';
import { ItemPageMetadataGuard } from './item-page-metadata.guard';
import { ItemPageRegisterDoiGuard } from './item-page-register-doi.guard';
import { ItemPageReinstateGuard } from './item-page-reinstate.guard';
import { ItemPageRelationshipsGuard } from './item-page-relationships.guard';
import { ItemPageStatusGuard } from './item-page-status.guard';
import { ItemPageVersionHistoryGuard } from './item-page-version-history.guard';
import { ItemPageWithdrawGuard } from './item-page-withdraw.guard';
import { itemPageAccessControlGuard } from './item-page-access-control.guard';
import { itemPageBitstreamsGuard } from './item-page-bitstreams.guard';
import { itemPageCollectionMapperGuard } from './item-page-collection-mapper.guard';
import { itemPageCurateGuard } from './item-page-curate.guard';
import { itemPageDeleteGuard } from './item-page-delete.guard';
import { itemPageEditAuthorizationsGuard } from './item-page-edit-authorizations.guard';
import { itemPageMetadataGuard } from './item-page-metadata.guard';
import { itemPageMoveGuard } from './item-page-move.guard';
import { itemPagePrivateGuard } from './item-page-private.guard';
import { itemPageRegisterDoiGuard } from './item-page-register-doi.guard';
import { itemPageReinstateGuard } from './item-page-reinstate.guard';
import { itemPageRelationshipsGuard } from './item-page-relationships.guard';
import { itemPageStatusGuard } from './item-page-status.guard';
import { itemPageVersionHistoryGuard } from './item-page-version-history.guard';
import { itemPageWithdrawGuard } from './item-page-withdraw.guard';
import { ItemPrivateComponent } from './item-private/item-private.component';
import { ItemPublicComponent } from './item-public/item-public.component';
import { ItemRegisterDoiComponent } from './item-register-doi/item-register-doi.component';
@@ -72,31 +73,31 @@ export const ROUTES: Route[] = [
path: 'status',
component: ThemedItemStatusComponent,
data: { title: 'item.edit.tabs.status.title', showBreadcrumbs: true },
canActivate: mapToCanActivate([ItemPageStatusGuard]),
canActivate: [itemPageStatusGuard],
},
{
path: 'bitstreams',
component: ItemBitstreamsComponent,
data: { title: 'item.edit.tabs.bitstreams.title', showBreadcrumbs: true },
canActivate: mapToCanActivate([ItemPageBitstreamsGuard]),
canActivate: [itemPageBitstreamsGuard],
},
{
path: 'metadata',
component: ThemedDsoEditMetadataComponent,
data: { title: 'item.edit.tabs.metadata.title', showBreadcrumbs: true },
canActivate: mapToCanActivate([ItemPageMetadataGuard]),
canActivate: [itemPageMetadataGuard],
},
{
path: 'curate',
component: ItemCurateComponent,
data: { title: 'item.edit.tabs.curate.title', showBreadcrumbs: true },
canActivate: mapToCanActivate([ItemPageCurateGuard]),
canActivate: [itemPageCurateGuard],
},
{
path: 'relationships',
component: ItemRelationshipsComponent,
data: { title: 'item.edit.tabs.relationships.title', showBreadcrumbs: true },
canActivate: mapToCanActivate([ItemPageRelationshipsGuard]),
canActivate: [itemPageRelationshipsGuard],
},
/* TODO - uncomment & fix when view page exists
{
@@ -114,19 +115,19 @@ export const ROUTES: Route[] = [
path: 'versionhistory',
component: ItemVersionHistoryComponent,
data: { title: 'item.edit.tabs.versionhistory.title', showBreadcrumbs: true },
canActivate: mapToCanActivate([ItemPageVersionHistoryGuard]),
canActivate: [itemPageVersionHistoryGuard],
},
{
path: 'access-control',
component: ItemAccessControlComponent,
data: { title: 'item.edit.tabs.access-control.title', showBreadcrumbs: true },
canActivate: mapToCanActivate([ItemPageAccessControlGuard]),
canActivate: [itemPageAccessControlGuard],
},
{
path: 'mapper',
component: ItemCollectionMapperComponent,
data: { title: 'item.edit.tabs.item-mapper.title', showBreadcrumbs: true },
canActivate: mapToCanActivate([ItemPageCollectionMapperGuard]),
canActivate: [itemPageCollectionMapperGuard],
},
],
},
@@ -137,16 +138,17 @@ export const ROUTES: Route[] = [
{
path: ITEM_EDIT_WITHDRAW_PATH,
component: ItemWithdrawComponent,
canActivate: mapToCanActivate([ItemPageWithdrawGuard]),
canActivate: [itemPageWithdrawGuard],
},
{
path: ITEM_EDIT_REINSTATE_PATH,
component: ItemReinstateComponent,
canActivate: mapToCanActivate([ItemPageReinstateGuard]),
canActivate: [itemPageReinstateGuard],
},
{
path: ITEM_EDIT_PRIVATE_PATH,
component: ItemPrivateComponent,
canActivate: [itemPagePrivateGuard],
},
{
path: ITEM_EDIT_PUBLIC_PATH,
@@ -155,16 +157,18 @@ export const ROUTES: Route[] = [
{
path: ITEM_EDIT_DELETE_PATH,
component: ItemDeleteComponent,
canActivate: [itemPageDeleteGuard],
},
{
path: ITEM_EDIT_MOVE_PATH,
component: ItemMoveComponent,
data: { title: 'item.edit.move.title' },
canActivate: [itemPageMoveGuard],
},
{
path: ITEM_EDIT_REGISTER_DOI_PATH,
component: ItemRegisterDoiComponent,
canActivate: mapToCanActivate([ItemPageRegisterDoiGuard]),
canActivate: [itemPageRegisterDoiGuard],
data: { title: 'item.edit.register-doi.title' },
},
{
@@ -192,6 +196,7 @@ export const ROUTES: Route[] = [
data: { title: 'item.edit.authorizations.title' },
},
],
canActivate: [itemPageEditAuthorizationsGuard],
},
],
},

View File

@@ -6,21 +6,21 @@
class="fas fa-upload"></i>
<span class="d-none d-sm-inline">&nbsp;{{"item.edit.bitstreams.upload-button" | translate}}</span>
</button>
<button class="btn btn-warning" *ngIf="isReinstatable() | async"
<button class="btn btn-warning" *ngIf="isReinstatable$ | async"
[attr.aria-label]="'item.edit.bitstreams.reinstate-button' | translate"
(click)="reinstate()"><i
class="fas fa-undo-alt"></i>
<span class="d-none d-sm-inline">&nbsp;{{"item.edit.bitstreams.reinstate-button" | translate}}</span>
</button>
<button class="btn btn-primary" [disabled]="(hasChanges() | async) !== true || submitting"
<button class="btn btn-primary" [disabled]="(hasChanges$ | async) !== true || submitting"
[attr.aria-label]="'item.edit.bitstreams.save-button' | translate"
(click)="submit()"><i
class="fas fa-save"></i>
<span class="d-none d-sm-inline">&nbsp;{{"item.edit.bitstreams.save-button" | translate}}</span>
</button>
<button class="btn btn-danger" *ngIf="(isReinstatable() | async) !== true"
<button class="btn btn-danger" *ngIf="(isReinstatable$ | async) !== true"
[attr.aria-label]="'item.edit.bitstreams.discard-button' | translate"
[disabled]="(hasChanges() | async) !== true || submitting"
[disabled]="(hasChanges$ | async) !== true || submitting"
(click)="discard()"><i
class="fas fa-times"></i>
<span class="d-none d-sm-inline">&nbsp;{{"item.edit.bitstreams.discard-button" | translate}}</span>
@@ -52,21 +52,21 @@
<div class="button-row bottom">
<div class="mt-4 float-right space-children-mr ml-gap">
<button class="btn btn-warning" *ngIf="isReinstatable() | async"
<button class="btn btn-warning" *ngIf="isReinstatable$ | async"
[attr.aria-label]="'item.edit.bitstreams.reinstate-button' | translate"
(click)="reinstate()"><i
class="fas fa-undo-alt"></i>
<span class="d-none d-sm-inline">&nbsp;{{"item.edit.bitstreams.reinstate-button" | translate}}</span>
</button>
<button class="btn btn-primary" [disabled]="(hasChanges() | async) !== true || submitting"
<button class="btn btn-primary" [disabled]="(hasChanges$ | async) !== true || submitting"
[attr.aria-label]="'item.edit.bitstreams.save-button' | translate"
(click)="submit()"><i
class="fas fa-save"></i>
<span class="d-none d-sm-inline">&nbsp;{{"item.edit.bitstreams.save-button" | translate}}</span>
</button>
<button class="btn btn-danger" *ngIf="(isReinstatable() | async) !== true"
<button class="btn btn-danger" *ngIf="(isReinstatable$ | async) !== true"
[attr.aria-label]="'item.edit.bitstreams.discard-button' | translate"
[disabled]="(hasChanges() | async) !== true || submitting"
[disabled]="(hasChanges$ | async) !== true || submitting"
(click)="discard()"><i
class="fas fa-times"></i>
<span class="d-none d-sm-inline">&nbsp;{{"item.edit.bitstreams.discard-button" | translate}}</span>

View File

@@ -6,30 +6,29 @@
<p>{{descriptionMessage | translate}}</p>
<ds-modify-item-overview [item]="item"></ds-modify-item-overview>
<ng-container *ngVar="(types$ | async) as types">
<ng-container *ngVar="(typeDTOs$ | async) as types">
<div *ngIf="types && types.length > 0" class="mb-4">
{{'virtual-metadata.delete-item.info' | translate}}
<div *ngFor="let type of types" class="mb-4">
<div *ngVar="(isSelected(type) | async) as selected"
<div *ngFor="let typeDto of types" class="mb-4">
<div *ngVar="(typeDto.isSelected$ | async) as selected"
class="d-flex flex-row">
<div class="m-2" (click)="setSelected(type, !selected)">
<div class="m-2" (click)="setSelected(typeDto.relationshipType, !selected)">
<label>
<input type="checkbox" [checked]="selected">
<input type="checkbox" [checked]="selected" [disabled]="isDeleting$ | async">
</label>
</div>
<div class="flex-column flex-grow-1">
<h5 (click)="setSelected(type, !selected)">
{{getRelationshipMessageKey(getLabel(type) | async) | translate}}
<h5 (click)="setSelected(typeDto.relationshipType, !selected)">
{{getRelationshipMessageKey(typeDto.label$ | async) | translate}}
</h5>
<div *ngFor="let relationship of (getRelationships(type) | async)"
<div *ngFor="let relationshipDto of (typeDto.relationshipDTOs$ | async)"
class="d-flex flex-row">
<ng-container *ngVar="(getRelatedItem(relationship) | async) as relatedItem">
<ng-container *ngVar="(relationshipDto.relatedItem$ | async) as relatedItem">
<ds-listable-object-component-loader
*ngIf="relatedItem"
@@ -46,7 +45,7 @@
</div>
<ng-template #virtualMetadataModal>
<div>
<div class="thumb-font-1">
<div class="modal-header">
{{'virtual-metadata.delete-item.modal-head' | translate}}
<button type="button" class="close"
@@ -60,7 +59,7 @@
[object]="relatedItem"
[viewMode]="viewMode">
</ds-listable-object-component-loader>
<div *ngFor="let metadata of (getVirtualMetadata(relationship) | async)">
<div *ngFor="let metadata of (relationshipDto.virtualMetadata$ | async)">
<div>
<div class="font-weight-bold">
{{metadata.metadataField}}
@@ -87,10 +86,11 @@
</ng-container>
<div class="space-children-mr">
<button (click)="performAction()"
<button [disabled]="isDeleting$ | async" (click)="performAction()"
class="btn btn-outline-secondary perform-action">{{confirmMessage | translate}}
</button>
<button [routerLink]="[itemPageRoute, 'edit']" class="btn btn-outline-secondary cancel">
<button [disabled]="isDeleting$ | async" [routerLink]="[itemPageRoute, 'edit']"
class="btn btn-outline-secondary cancel">
{{cancelMessage| translate}}
</button>
</div>

View File

@@ -1,3 +1,4 @@
// eslint-disable-next-line max-classes-per-file
import {
AsyncPipe,
NgForOf,
@@ -68,6 +69,34 @@ import { ModifyItemOverviewComponent } from '../modify-item-overview/modify-item
import { AbstractSimpleItemActionComponent } from '../simple-item-action/abstract-simple-item-action.component';
import { VirtualMetadata } from '../virtual-metadata/virtual-metadata.component';
/**
* Data Transfer Object used to prevent the HTML template to call function returning Observables
*/
class RelationshipTypeDTO {
relationshipType: RelationshipType;
isSelected$: Observable<boolean>;
label$: Observable<string>;
relationshipDTOs$: Observable<RelationshipDTO[]>;
}
/**
* Data Transfer Object used to prevent the HTML template to call function returning Observables
*/
class RelationshipDTO {
relationship: Relationship;
relatedItem$: Observable<Item>;
virtualMetadata$: Observable<VirtualMetadata[]>;
}
@Component({
selector: 'ds-item-delete',
templateUrl: '../item-delete/item-delete.component.html',
@@ -106,7 +135,7 @@ export class ItemDeleteComponent
* A list of the relationship types for which this item has relations as an observable.
* The list doesn't contain duplicates.
*/
types$: BehaviorSubject<RelationshipType[]> = new BehaviorSubject([]);
typeDTOs$: BehaviorSubject<RelationshipTypeDTO[]> = new BehaviorSubject([]);
/**
* A map which stores the relationships of this item for each type as observable lists
@@ -135,6 +164,8 @@ export class ItemDeleteComponent
*/
private subs: Subscription[] = [];
public isDeleting$: BehaviorSubject<boolean> = new BehaviorSubject(false);
constructor(protected route: ActivatedRoute,
protected router: Router,
protected notificationsService: NotificationsService,
@@ -189,13 +220,24 @@ export class ItemDeleteComponent
),
);
}),
).subscribe((types: RelationshipType[]) => this.types$.next(types)));
).subscribe((types: RelationshipType[]) => this.typeDTOs$.next(types.map((relationshipType: RelationshipType) => Object.assign(new RelationshipTypeDTO(), {
relationshipType: relationshipType,
isSelected$: this.isSelected(relationshipType),
label$: this.getLabel(relationshipType),
relationshipDTOs$: this.getRelationships(relationshipType).pipe(
map((relationships: Relationship[]) => relationships.map((relationship: Relationship) => Object.assign(new RelationshipDTO(), {
relationship: relationship,
relatedItem$: this.getRelatedItem(relationship),
virtualMetadata$: this.getVirtualMetadata(relationship),
} as RelationshipDTO))),
),
})))));
}
this.subs.push(this.types$.pipe(
this.subs.push(this.typeDTOs$.pipe(
take(1),
).subscribe((types) =>
this.objectUpdatesService.initialize(this.url, types, this.item.lastModified),
).subscribe((types: RelationshipTypeDTO[]) =>
this.objectUpdatesService.initialize(this.url, types.map((relationshipTypeDto: RelationshipTypeDTO) => relationshipTypeDto.relationshipType), this.item.lastModified),
));
}
@@ -368,34 +410,33 @@ export class ItemDeleteComponent
* @param selected whether the type should be selected
*/
setSelected(type: RelationshipType, selected: boolean): void {
if (this.isDeleting$.value === false) {
this.objectUpdatesService.setSelectedVirtualMetadata(this.url, this.item.uuid, type.uuid, selected);
}
}
/**
* Perform the delete operation
*/
performAction() {
this.subs.push(this.types$.pipe(
switchMap((types) =>
performAction(): void {
this.isDeleting$.next(true);
this.subs.push(this.typeDTOs$.pipe(
switchMap((types: RelationshipTypeDTO[]) =>
combineLatest(
types.map((type) => this.isSelected(type)),
types.map((type: RelationshipTypeDTO) => type.isSelected$),
).pipe(
defaultIfEmpty([]),
map((selection) => types.filter(
(type, index) => selection[index],
map((selection: boolean[]) => types.filter(
(type: RelationshipTypeDTO, index: number) => selection[index],
)),
map((selectedTypes) => selectedTypes.map((type) => type.id)),
map((selectedDtoTypes: RelationshipTypeDTO[]) => selectedDtoTypes.map((typeDto: RelationshipTypeDTO) => typeDto.relationshipType.id)),
),
),
switchMap((types) =>
this.itemDataService.delete(this.item.id, types).pipe(getFirstCompletedRemoteData()),
),
).subscribe(
(rd: RemoteData<NoContent>) => {
switchMap((types: string[]) => this.itemDataService.delete(this.item.id, types)),
getFirstCompletedRemoteData(),
).subscribe((rd: RemoteData<NoContent>) => {
this.notify(rd.hasSucceeded);
},
));
}));
}
/**
@@ -405,10 +446,10 @@ export class ItemDeleteComponent
notify(succeeded: boolean) {
if (succeeded) {
this.notificationsService.success(this.translateService.get('item.edit.' + this.messageKey + '.success'));
this.router.navigate(['']);
void this.router.navigate(['']);
} else {
this.notificationsService.error(this.translateService.get('item.edit.' + this.messageKey + '.error'));
this.router.navigate([getItemEditRoute(this.item)]);
void this.router.navigate([getItemEditRoute(this.item)]);
}
}

View File

@@ -1,43 +1,15 @@
import { Injectable } from '@angular/core';
import {
ActivatedRouteSnapshot,
ResolveFn,
Router,
RouterStateSnapshot,
} from '@angular/router';
import {
Observable,
of as observableOf,
} from 'rxjs';
import { CanActivateFn } from '@angular/router';
import { of as observableOf } from 'rxjs';
import { AuthService } from '../../core/auth/auth.service';
import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service';
import { DsoPageSingleFeatureGuard } from '../../core/data/feature-authorization/feature-authorization-guard/dso-page-single-feature.guard';
import { dsoPageSingleFeatureGuard } from '../../core/data/feature-authorization/feature-authorization-guard/dso-page-single-feature.guard';
import { FeatureID } from '../../core/data/feature-authorization/feature-id';
import { RemoteData } from '../../core/data/remote-data';
import { Item } from '../../core/shared/item.model';
import { itemPageResolver } from '../item-page.resolver';
@Injectable({
providedIn: 'root',
})
/**
* Guard for preventing unauthorized access to certain {@link Item} pages requiring administrator rights
*/
export class ItemPageAccessControlGuard extends DsoPageSingleFeatureGuard<Item> {
protected resolver: ResolveFn<RemoteData<Item>> = itemPageResolver;
constructor(protected authorizationService: AuthorizationDataService,
protected router: Router,
protected authService: AuthService) {
super(authorizationService, router, authService);
}
/**
* Check administrator authorization rights
*/
getFeatureID(next: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<FeatureID> {
return observableOf(FeatureID.AdministratorOf);
}
}
export const itemPageAccessControlGuard: CanActivateFn =
dsoPageSingleFeatureGuard(
() => itemPageResolver,
() => observableOf(FeatureID.AdministratorOf),
);

View File

@@ -1,43 +1,16 @@
import { Injectable } from '@angular/core';
import {
ActivatedRouteSnapshot,
ResolveFn,
Router,
RouterStateSnapshot,
} from '@angular/router';
import {
Observable,
of as observableOf,
} from 'rxjs';
import { CanActivateFn } from '@angular/router';
import { of as observableOf } from 'rxjs';
import { AuthService } from '../../core/auth/auth.service';
import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service';
import { DsoPageSingleFeatureGuard } from '../../core/data/feature-authorization/feature-authorization-guard/dso-page-single-feature.guard';
import { dsoPageSingleFeatureGuard } from '../../core/data/feature-authorization/feature-authorization-guard/dso-page-single-feature.guard';
import { FeatureID } from '../../core/data/feature-authorization/feature-id';
import { RemoteData } from '../../core/data/remote-data';
import { Item } from '../../core/shared/item.model';
import { itemPageResolver } from '../item-page.resolver';
@Injectable({
providedIn: 'root',
})
/**
* Guard for preventing unauthorized access to certain {@link Item} pages requiring manage bitstreams rights
*/
export class ItemPageBitstreamsGuard extends DsoPageSingleFeatureGuard<Item> {
protected resolver: ResolveFn<RemoteData<Item>> = itemPageResolver;
constructor(protected authorizationService: AuthorizationDataService,
protected router: Router,
protected authService: AuthService) {
super( authorizationService, router, authService);
}
/**
* Check manage bitstreams authorization rights
*/
getFeatureID(next: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<FeatureID> {
return observableOf(FeatureID.CanManageBitstreamBundles);
}
}
export const itemPageBitstreamsGuard: CanActivateFn =
dsoPageSingleFeatureGuard(
() => itemPageResolver,
() => observableOf(FeatureID.CanManageBitstreamBundles),
);

View File

@@ -1,43 +1,16 @@
import { Injectable } from '@angular/core';
import {
ActivatedRouteSnapshot,
ResolveFn,
Router,
RouterStateSnapshot,
} from '@angular/router';
import {
Observable,
of as observableOf,
} from 'rxjs';
import { CanActivateFn } from '@angular/router';
import { of as observableOf } from 'rxjs';
import { AuthService } from '../../core/auth/auth.service';
import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service';
import { DsoPageSingleFeatureGuard } from '../../core/data/feature-authorization/feature-authorization-guard/dso-page-single-feature.guard';
import { dsoPageSingleFeatureGuard } from '../../core/data/feature-authorization/feature-authorization-guard/dso-page-single-feature.guard';
import { FeatureID } from '../../core/data/feature-authorization/feature-id';
import { RemoteData } from '../../core/data/remote-data';
import { Item } from '../../core/shared/item.model';
import { itemPageResolver } from '../item-page.resolver';
@Injectable({
providedIn: 'root',
})
/**
* Guard for preventing unauthorized access to certain {@link Item} pages requiring manage mappings rights
*/
export class ItemPageCollectionMapperGuard extends DsoPageSingleFeatureGuard<Item> {
protected resolver: ResolveFn<RemoteData<Item>> = itemPageResolver;
constructor(protected authorizationService: AuthorizationDataService,
protected router: Router,
protected authService: AuthService) {
super(authorizationService, router, authService);
}
/**
* Check manage mappings authorization rights
*/
getFeatureID(next: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<FeatureID> {
return observableOf(FeatureID.CanManageMappings);
}
}
export const itemPageCollectionMapperGuard: CanActivateFn =
dsoPageSingleFeatureGuard(
() => itemPageResolver,
() => observableOf(FeatureID.CanManageMappings),
);

View File

@@ -1,43 +1,15 @@
import { Injectable } from '@angular/core';
import {
ActivatedRouteSnapshot,
ResolveFn,
Router,
RouterStateSnapshot,
} from '@angular/router';
import {
Observable,
of as observableOf,
} from 'rxjs';
import { CanActivateFn } from '@angular/router';
import { of as observableOf } from 'rxjs';
import { AuthService } from '../../core/auth/auth.service';
import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service';
import { DsoPageSingleFeatureGuard } from '../../core/data/feature-authorization/feature-authorization-guard/dso-page-single-feature.guard';
import { dsoPageSingleFeatureGuard } from '../../core/data/feature-authorization/feature-authorization-guard/dso-page-single-feature.guard';
import { FeatureID } from '../../core/data/feature-authorization/feature-id';
import { RemoteData } from '../../core/data/remote-data';
import { Item } from '../../core/shared/item.model';
import { itemPageResolver } from '../item-page.resolver';
@Injectable({
providedIn: 'root',
})
/**
* Guard for preventing unauthorized access to certain {@link Item} pages requiring administrator rights
*/
export class ItemPageCurateGuard extends DsoPageSingleFeatureGuard<Item> {
protected resolver: ResolveFn<RemoteData<Item>> = itemPageResolver;
constructor(protected authorizationService: AuthorizationDataService,
protected router: Router,
protected authService: AuthService) {
super(authorizationService, router, authService);
}
/**
* Check administrator authorization rights
*/
getFeatureID(next: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<FeatureID> {
return observableOf(FeatureID.AdministratorOf);
}
}
export const itemPageCurateGuard: CanActivateFn =
dsoPageSingleFeatureGuard(
() => itemPageResolver,
() => observableOf(FeatureID.AdministratorOf),
);

View File

@@ -0,0 +1,94 @@
import { TestBed } from '@angular/core/testing';
import {
Router,
UrlTree,
} from '@angular/router';
import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import {
Observable,
of as observableOf,
} from 'rxjs';
import { AuthService } from 'src/app/core/auth/auth.service';
import { AuthorizationDataService } from 'src/app/core/data/feature-authorization/authorization-data.service';
import { FeatureID } from 'src/app/core/data/feature-authorization/feature-id';
import { APP_DATA_SERVICES_MAP } from '../../../config/app-config.interface';
import { ItemDataService } from '../../core/data/item-data.service';
import { Item } from '../../core/shared/item.model';
import { getMockTranslateService } from '../../shared/mocks/translate.service.mock';
import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
import { itemPageDeleteGuard } from './item-page-delete.guard';
describe('itemPageDeleteGuard', () => {
let authorizationService: AuthorizationDataService;
let authService: AuthService;
let router: Router;
let route;
let parentRoute;
let store: Store;
let itemService: ItemDataService;
let item: Item;
let uuid = '1234-abcdef-54321-fedcba';
let itemSelfLink = 'test.url/1234-abcdef-54321-fedcba';
beforeEach(() => {
store = jasmine.createSpyObj('store', {
dispatch: {},
pipe: observableOf(true),
});
authorizationService = jasmine.createSpyObj('authorizationService', {
isAuthorized: observableOf(true),
});
router = jasmine.createSpyObj('router', {
parseUrl: {},
navigateByUrl: undefined,
});
authService = jasmine.createSpyObj('authService', {
isAuthenticated: observableOf(true),
});
parentRoute = {
params: {
id: '3e1a5327-dabb-41ff-af93-e6cab9d032f0',
},
};
route = {
params: {},
parent: parentRoute,
};
item = new Item();
item.uuid = uuid;
item._links = { self: { href: itemSelfLink } } as any;
itemService = jasmine.createSpyObj('itemService', { findById: createSuccessfulRemoteDataObject$(item) });
TestBed.configureTestingModule({
providers: [
{ provide: AuthorizationDataService, useValue: authorizationService },
{ provide: Router, useValue: router },
{ provide: AuthService, useValue: authService },
{ provide: Store, useValue: store },
{ provide: APP_DATA_SERVICES_MAP, useValue: {} },
{ provide: TranslateService, useValue: getMockTranslateService() },
{ provide: ItemDataService, useValue: itemService },
],
});
});
it('should call authorizationService.isAuthorized with the appropriate arguments', (done) => {
const result$ = TestBed.runInInjectionContext(() => {
return itemPageDeleteGuard(route, { url: 'current-url' } as any);
}) as Observable<boolean | UrlTree>;
result$.subscribe((result) => {
expect(authorizationService.isAuthorized).toHaveBeenCalledWith(
FeatureID.CanDelete,
itemSelfLink, // This value is retrieved from the itemDataService.findById's return item's self link
undefined, // dsoPageSingleFeatureGuard never provides a function to retrieve a person ID
);
done();
});
});
});

View File

@@ -0,0 +1,16 @@
import { CanActivateFn } from '@angular/router';
import { of as observableOf } from 'rxjs';
import { dsoPageSingleFeatureGuard } from '../../core/data/feature-authorization/feature-authorization-guard/dso-page-single-feature.guard';
import { FeatureID } from '../../core/data/feature-authorization/feature-id';
import { itemPageResolver } from '../item-page.resolver';
/**
* Guard for preventing unauthorized access to certain {@link Item} pages requiring specific authorizations.
* Checks authorization rights for deleting items.
*/
export const itemPageDeleteGuard: CanActivateFn =
dsoPageSingleFeatureGuard(
() => itemPageResolver,
() => observableOf(FeatureID.CanDelete),
);

View File

@@ -0,0 +1,94 @@
import { TestBed } from '@angular/core/testing';
import {
Router,
UrlTree,
} from '@angular/router';
import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import {
Observable,
of as observableOf,
} from 'rxjs';
import { AuthService } from 'src/app/core/auth/auth.service';
import { AuthorizationDataService } from 'src/app/core/data/feature-authorization/authorization-data.service';
import { FeatureID } from 'src/app/core/data/feature-authorization/feature-id';
import { APP_DATA_SERVICES_MAP } from '../../../config/app-config.interface';
import { ItemDataService } from '../../core/data/item-data.service';
import { Item } from '../../core/shared/item.model';
import { getMockTranslateService } from '../../shared/mocks/translate.service.mock';
import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
import { itemPageEditAuthorizationsGuard } from './item-page-edit-authorizations.guard';
describe('itemPageEditAuthorizationsGuard', () => {
let authorizationService: AuthorizationDataService;
let authService: AuthService;
let router: Router;
let route;
let parentRoute;
let store: Store;
let itemService: ItemDataService;
let item: Item;
let uuid = '1234-abcdef-54321-fedcba';
let itemSelfLink = 'test.url/1234-abcdef-54321-fedcba';
beforeEach(() => {
store = jasmine.createSpyObj('store', {
dispatch: {},
pipe: observableOf(true),
});
authorizationService = jasmine.createSpyObj('authorizationService', {
isAuthorized: observableOf(true),
});
router = jasmine.createSpyObj('router', {
parseUrl: {},
navigateByUrl: undefined,
});
authService = jasmine.createSpyObj('authService', {
isAuthenticated: observableOf(true),
});
parentRoute = {
params: {
id: '3e1a5327-dabb-41ff-af93-e6cab9d032f0',
},
};
route = {
params: {},
parent: parentRoute,
};
item = new Item();
item.uuid = uuid;
item._links = { self: { href: itemSelfLink } } as any;
itemService = jasmine.createSpyObj('itemService', { findById: createSuccessfulRemoteDataObject$(item) });
TestBed.configureTestingModule({
providers: [
{ provide: AuthorizationDataService, useValue: authorizationService },
{ provide: Router, useValue: router },
{ provide: AuthService, useValue: authService },
{ provide: Store, useValue: store },
{ provide: APP_DATA_SERVICES_MAP, useValue: {} },
{ provide: TranslateService, useValue: getMockTranslateService() },
{ provide: ItemDataService, useValue: itemService },
],
});
});
it('should call authorizationService.isAuthorized with the appropriate arguments', (done) => {
const result$ = TestBed.runInInjectionContext(() => {
return itemPageEditAuthorizationsGuard(route, { url: 'current-url' } as any);
}) as Observable<boolean | UrlTree>;
result$.subscribe((result) => {
expect(authorizationService.isAuthorized).toHaveBeenCalledWith(
FeatureID.CanManagePolicies,
itemSelfLink, // This value is retrieved from the itemDataService.findById's return item's self link
undefined, // dsoPageSingleFeatureGuard never provides a function to retrieve a person ID
);
done();
});
});
});

View File

@@ -0,0 +1,16 @@
import { CanActivateFn } from '@angular/router';
import { of as observableOf } from 'rxjs';
import { dsoPageSingleFeatureGuard } from '../../core/data/feature-authorization/feature-authorization-guard/dso-page-single-feature.guard';
import { FeatureID } from '../../core/data/feature-authorization/feature-id';
import { itemPageResolver } from '../item-page.resolver';
/**
* Guard for preventing unauthorized access to certain {@link Item} pages requiring specific authorizations.
* Checks authorization rights for managing policies.
*/
export const itemPageEditAuthorizationsGuard: CanActivateFn =
dsoPageSingleFeatureGuard(
() => itemPageResolver,
() => observableOf(FeatureID.CanManagePolicies),
);

View File

@@ -1,43 +1,16 @@
import { Injectable } from '@angular/core';
import {
ActivatedRouteSnapshot,
ResolveFn,
Router,
RouterStateSnapshot,
} from '@angular/router';
import {
Observable,
of as observableOf,
} from 'rxjs';
import { CanActivateFn } from '@angular/router';
import { of as observableOf } from 'rxjs';
import { AuthService } from '../../core/auth/auth.service';
import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service';
import { DsoPageSingleFeatureGuard } from '../../core/data/feature-authorization/feature-authorization-guard/dso-page-single-feature.guard';
import { dsoPageSingleFeatureGuard } from '../../core/data/feature-authorization/feature-authorization-guard/dso-page-single-feature.guard';
import { FeatureID } from '../../core/data/feature-authorization/feature-id';
import { RemoteData } from '../../core/data/remote-data';
import { Item } from '../../core/shared/item.model';
import { itemPageResolver } from '../item-page.resolver';
@Injectable({
providedIn: 'root',
})
/**
* Guard for preventing unauthorized access to certain {@link Item} pages requiring edit metadata rights
*/
export class ItemPageMetadataGuard extends DsoPageSingleFeatureGuard<Item> {
protected resolver: ResolveFn<RemoteData<Item>> = itemPageResolver;
constructor(protected authorizationService: AuthorizationDataService,
protected router: Router,
protected authService: AuthService) {
super(authorizationService, router, authService);
}
/**
* Check edit metadata authorization rights
*/
getFeatureID(next: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<FeatureID> {
return observableOf(FeatureID.CanEditMetadata);
}
}
export const itemPageMetadataGuard: CanActivateFn =
dsoPageSingleFeatureGuard(
() => itemPageResolver,
() => observableOf(FeatureID.CanEditMetadata),
);

View File

@@ -0,0 +1,94 @@
import { TestBed } from '@angular/core/testing';
import {
Router,
UrlTree,
} from '@angular/router';
import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import {
Observable,
of as observableOf,
} from 'rxjs';
import { AuthService } from 'src/app/core/auth/auth.service';
import { AuthorizationDataService } from 'src/app/core/data/feature-authorization/authorization-data.service';
import { FeatureID } from 'src/app/core/data/feature-authorization/feature-id';
import { APP_DATA_SERVICES_MAP } from '../../../config/app-config.interface';
import { ItemDataService } from '../../core/data/item-data.service';
import { Item } from '../../core/shared/item.model';
import { getMockTranslateService } from '../../shared/mocks/translate.service.mock';
import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
import { itemPageMoveGuard } from './item-page-move.guard';
describe('itemPageMoveGuard', () => {
let authorizationService: AuthorizationDataService;
let authService: AuthService;
let router: Router;
let route;
let parentRoute;
let store: Store;
let itemService: ItemDataService;
let item: Item;
let uuid = '1234-abcdef-54321-fedcba';
let itemSelfLink = 'test.url/1234-abcdef-54321-fedcba';
beforeEach(() => {
store = jasmine.createSpyObj('store', {
dispatch: {},
pipe: observableOf(true),
});
authorizationService = jasmine.createSpyObj('authorizationService', {
isAuthorized: observableOf(true),
});
router = jasmine.createSpyObj('router', {
parseUrl: {},
navigateByUrl: undefined,
});
authService = jasmine.createSpyObj('authService', {
isAuthenticated: observableOf(true),
});
parentRoute = {
params: {
id: '3e1a5327-dabb-41ff-af93-e6cab9d032f0',
},
};
route = {
params: {},
parent: parentRoute,
};
item = new Item();
item.uuid = uuid;
item._links = { self: { href: itemSelfLink } } as any;
itemService = jasmine.createSpyObj('itemService', { findById: createSuccessfulRemoteDataObject$(item) });
TestBed.configureTestingModule({
providers: [
{ provide: AuthorizationDataService, useValue: authorizationService },
{ provide: Router, useValue: router },
{ provide: AuthService, useValue: authService },
{ provide: Store, useValue: store },
{ provide: APP_DATA_SERVICES_MAP, useValue: {} },
{ provide: TranslateService, useValue: getMockTranslateService() },
{ provide: ItemDataService, useValue: itemService },
],
});
});
it('should call authorizationService.isAuthorized with the appropriate arguments', (done) => {
const result$ = TestBed.runInInjectionContext(() => {
return itemPageMoveGuard(route, { url: 'current-url' } as any);
}) as Observable<boolean | UrlTree>;
result$.subscribe((result) => {
expect(authorizationService.isAuthorized).toHaveBeenCalledWith(
FeatureID.CanMove,
itemSelfLink, // This value is retrieved from the itemDataService.findById's return item's self link
undefined, // dsoPageSingleFeatureGuard never provides a function to retrieve a person ID
);
done();
});
});
});

View File

@@ -0,0 +1,16 @@
import { CanActivateFn } from '@angular/router';
import { of as observableOf } from 'rxjs';
import { dsoPageSingleFeatureGuard } from '../../core/data/feature-authorization/feature-authorization-guard/dso-page-single-feature.guard';
import { FeatureID } from '../../core/data/feature-authorization/feature-id';
import { itemPageResolver } from '../item-page.resolver';
/**
* Guard for preventing unauthorized access to certain {@link Item} pages requiring specific authorizations.
* Checks authorization rights for moving items.
*/
export const itemPageMoveGuard: CanActivateFn =
dsoPageSingleFeatureGuard(
() => itemPageResolver,
() => observableOf(FeatureID.CanMove),
);

View File

@@ -0,0 +1,94 @@
import { TestBed } from '@angular/core/testing';
import {
Router,
UrlTree,
} from '@angular/router';
import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import {
Observable,
of as observableOf,
} from 'rxjs';
import { AuthService } from 'src/app/core/auth/auth.service';
import { AuthorizationDataService } from 'src/app/core/data/feature-authorization/authorization-data.service';
import { FeatureID } from 'src/app/core/data/feature-authorization/feature-id';
import { APP_DATA_SERVICES_MAP } from '../../../config/app-config.interface';
import { ItemDataService } from '../../core/data/item-data.service';
import { Item } from '../../core/shared/item.model';
import { getMockTranslateService } from '../../shared/mocks/translate.service.mock';
import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
import { itemPagePrivateGuard } from './item-page-private.guard';
describe('itemPagePrivateGuard', () => {
let authorizationService: AuthorizationDataService;
let authService: AuthService;
let router: Router;
let route;
let parentRoute;
let store: Store;
let itemService: ItemDataService;
let item: Item;
let uuid = '1234-abcdef-54321-fedcba';
let itemSelfLink = 'test.url/1234-abcdef-54321-fedcba';
beforeEach(() => {
store = jasmine.createSpyObj('store', {
dispatch: {},
pipe: observableOf(true),
});
authorizationService = jasmine.createSpyObj('authorizationService', {
isAuthorized: observableOf(true),
});
router = jasmine.createSpyObj('router', {
parseUrl: {},
navigateByUrl: undefined,
});
authService = jasmine.createSpyObj('authService', {
isAuthenticated: observableOf(true),
});
parentRoute = {
params: {
id: '3e1a5327-dabb-41ff-af93-e6cab9d032f0',
},
};
route = {
params: {},
parent: parentRoute,
};
item = new Item();
item.uuid = uuid;
item._links = { self: { href: itemSelfLink } } as any;
itemService = jasmine.createSpyObj('itemService', { findById: createSuccessfulRemoteDataObject$(item) });
TestBed.configureTestingModule({
providers: [
{ provide: AuthorizationDataService, useValue: authorizationService },
{ provide: Router, useValue: router },
{ provide: AuthService, useValue: authService },
{ provide: Store, useValue: store },
{ provide: APP_DATA_SERVICES_MAP, useValue: {} },
{ provide: TranslateService, useValue: getMockTranslateService() },
{ provide: ItemDataService, useValue: itemService },
],
});
});
it('should call authorizationService.isAuthorized with the appropriate arguments', (done) => {
const result$ = TestBed.runInInjectionContext(() => {
return itemPagePrivateGuard(route, { url: 'current-url' } as any);
}) as Observable<boolean | UrlTree>;
result$.subscribe((result) => {
expect(authorizationService.isAuthorized).toHaveBeenCalledWith(
FeatureID.CanMakePrivate,
itemSelfLink, // This value is retrieved from the itemDataService.findById's return item's self link
undefined, // dsoPageSingleFeatureGuard never provides a function to retrieve a person ID
);
done();
});
});
});

View File

@@ -0,0 +1,16 @@
import { CanActivateFn } from '@angular/router';
import { of as observableOf } from 'rxjs';
import { dsoPageSingleFeatureGuard } from '../../core/data/feature-authorization/feature-authorization-guard/dso-page-single-feature.guard';
import { FeatureID } from '../../core/data/feature-authorization/feature-id';
import { itemPageResolver } from '../item-page.resolver';
/**
* Guard for preventing unauthorized access to certain {@link Item} pages requiring specific authorizations.
* Checks authorization rights for making items private.
*/
export const itemPagePrivateGuard: CanActivateFn =
dsoPageSingleFeatureGuard(
() => itemPageResolver,
() => observableOf(FeatureID.CanMakePrivate),
);

View File

@@ -1,43 +1,16 @@
import { Injectable } from '@angular/core';
import {
ActivatedRouteSnapshot,
ResolveFn,
Router,
RouterStateSnapshot,
} from '@angular/router';
import {
Observable,
of as observableOf,
} from 'rxjs';
import { CanActivateFn } from '@angular/router';
import { of as observableOf } from 'rxjs';
import { AuthService } from '../../core/auth/auth.service';
import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service';
import { DsoPageSingleFeatureGuard } from '../../core/data/feature-authorization/feature-authorization-guard/dso-page-single-feature.guard';
import { dsoPageSingleFeatureGuard } from '../../core/data/feature-authorization/feature-authorization-guard/dso-page-single-feature.guard';
import { FeatureID } from '../../core/data/feature-authorization/feature-id';
import { RemoteData } from '../../core/data/remote-data';
import { Item } from '../../core/shared/item.model';
import { itemPageResolver } from '../item-page.resolver';
@Injectable({
providedIn: 'root',
})
/**
* Guard for preventing unauthorized access to certain {@link Item} pages requiring DOI registration rights
*/
export class ItemPageRegisterDoiGuard extends DsoPageSingleFeatureGuard<Item> {
protected resolver: ResolveFn<RemoteData<Item>> = itemPageResolver;
constructor(protected authorizationService: AuthorizationDataService,
protected router: Router,
protected authService: AuthService) {
super(authorizationService, router, authService);
}
/**
* Check DOI registration authorization rights
*/
getFeatureID(next: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<FeatureID> {
return observableOf(FeatureID.CanRegisterDOI);
}
}
export const itemPageRegisterDoiGuard: CanActivateFn =
dsoPageSingleFeatureGuard(
() => itemPageResolver,
() => observableOf(FeatureID.CanRegisterDOI),
);

View File

@@ -1,43 +1,16 @@
import { Injectable } from '@angular/core';
import {
ActivatedRouteSnapshot,
ResolveFn,
Router,
RouterStateSnapshot,
} from '@angular/router';
import {
Observable,
of as observableOf,
} from 'rxjs';
import { CanActivateFn } from '@angular/router';
import { of as observableOf } from 'rxjs';
import { AuthService } from '../../core/auth/auth.service';
import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service';
import { DsoPageSingleFeatureGuard } from '../../core/data/feature-authorization/feature-authorization-guard/dso-page-single-feature.guard';
import { dsoPageSingleFeatureGuard } from '../../core/data/feature-authorization/feature-authorization-guard/dso-page-single-feature.guard';
import { FeatureID } from '../../core/data/feature-authorization/feature-id';
import { RemoteData } from '../../core/data/remote-data';
import { Item } from '../../core/shared/item.model';
import { itemPageResolver } from '../item-page.resolver';
@Injectable({
providedIn: 'root',
})
/**
* Guard for preventing unauthorized access to certain {@link Item} pages requiring reinstate rights
*/
export class ItemPageReinstateGuard extends DsoPageSingleFeatureGuard<Item> {
protected resolver: ResolveFn<RemoteData<Item>> = itemPageResolver;
constructor(protected authorizationService: AuthorizationDataService,
protected router: Router,
protected authService: AuthService) {
super(authorizationService, router, authService);
}
/**
* Check reinstate authorization rights
*/
getFeatureID(next: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<FeatureID> {
return observableOf(FeatureID.ReinstateItem);
}
}
export const itemPageReinstateGuard: CanActivateFn =
dsoPageSingleFeatureGuard(
() => itemPageResolver,
() => observableOf(FeatureID.ReinstateItem),
);

View File

@@ -1,43 +1,16 @@
import { Injectable } from '@angular/core';
import {
ActivatedRouteSnapshot,
ResolveFn,
Router,
RouterStateSnapshot,
} from '@angular/router';
import {
Observable,
of as observableOf,
} from 'rxjs';
import { CanActivateFn } from '@angular/router';
import { of as observableOf } from 'rxjs';
import { AuthService } from '../../core/auth/auth.service';
import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service';
import { DsoPageSingleFeatureGuard } from '../../core/data/feature-authorization/feature-authorization-guard/dso-page-single-feature.guard';
import { dsoPageSingleFeatureGuard } from '../../core/data/feature-authorization/feature-authorization-guard/dso-page-single-feature.guard';
import { FeatureID } from '../../core/data/feature-authorization/feature-id';
import { RemoteData } from '../../core/data/remote-data';
import { Item } from '../../core/shared/item.model';
import { itemPageResolver } from '../item-page.resolver';
@Injectable({
providedIn: 'root',
})
/**
* Guard for preventing unauthorized access to certain {@link Item} pages requiring manage relationships rights
*/
export class ItemPageRelationshipsGuard extends DsoPageSingleFeatureGuard<Item> {
protected resolver: ResolveFn<RemoteData<Item>> = itemPageResolver;
constructor(protected authorizationService: AuthorizationDataService,
protected router: Router,
protected authService: AuthService) {
super(authorizationService, router, authService);
}
/**
* Check manage relationships authorization rights
*/
getFeatureID(next: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<FeatureID> {
return observableOf(FeatureID.CanManageRelationships);
}
}
export const itemPageRelationshipsGuard: CanActivateFn =
dsoPageSingleFeatureGuard(
() => itemPageResolver,
() => observableOf(FeatureID.CanManageRelationships),
);

View File

@@ -1,44 +1,17 @@
import { Injectable } from '@angular/core';
import {
ActivatedRouteSnapshot,
ResolveFn,
Router,
RouterStateSnapshot,
} from '@angular/router';
import {
Observable,
of as observableOf,
} from 'rxjs';
import { CanActivateFn } from '@angular/router';
import { of as observableOf } from 'rxjs';
import { AuthService } from '../../core/auth/auth.service';
import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service';
import { DsoPageSomeFeatureGuard } from '../../core/data/feature-authorization/feature-authorization-guard/dso-page-some-feature.guard';
import { dsoPageSomeFeatureGuard } from '../../core/data/feature-authorization/feature-authorization-guard/dso-page-some-feature.guard';
import { FeatureID } from '../../core/data/feature-authorization/feature-id';
import { RemoteData } from '../../core/data/remote-data';
import { Item } from '../../core/shared/item.model';
import { itemPageResolver } from '../item-page.resolver';
@Injectable({
providedIn: 'root',
})
/**
* Guard for preventing unauthorized access to certain {@link Item} pages requiring any of the rights required for
* the status page
*/
export class ItemPageStatusGuard extends DsoPageSomeFeatureGuard<Item> {
protected resolver: ResolveFn<RemoteData<Item>> = itemPageResolver;
constructor(protected authorizationService: AuthorizationDataService,
protected router: Router,
protected authService: AuthService) {
super(authorizationService, router, authService);
}
/**
* Check authorization rights
*/
getFeatureIDs(next: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<FeatureID[]> {
return observableOf([FeatureID.CanManageMappings, FeatureID.WithdrawItem, FeatureID.ReinstateItem, FeatureID.CanManagePolicies, FeatureID.CanMakePrivate, FeatureID.CanDelete, FeatureID.CanMove, FeatureID.CanRegisterDOI]);
}
}
export const itemPageStatusGuard: CanActivateFn =
dsoPageSomeFeatureGuard(
() => itemPageResolver,
() => observableOf([FeatureID.CanManageMappings, FeatureID.WithdrawItem, FeatureID.ReinstateItem, FeatureID.CanManagePolicies, FeatureID.CanMakePrivate, FeatureID.CanDelete, FeatureID.CanMove, FeatureID.CanRegisterDOI]),
);

View File

@@ -1,43 +1,16 @@
import { Injectable } from '@angular/core';
import {
ActivatedRouteSnapshot,
ResolveFn,
Router,
RouterStateSnapshot,
} from '@angular/router';
import {
Observable,
of as observableOf,
} from 'rxjs';
import { CanActivateFn } from '@angular/router';
import { of as observableOf } from 'rxjs';
import { AuthService } from '../../core/auth/auth.service';
import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service';
import { DsoPageSingleFeatureGuard } from '../../core/data/feature-authorization/feature-authorization-guard/dso-page-single-feature.guard';
import { dsoPageSingleFeatureGuard } from '../../core/data/feature-authorization/feature-authorization-guard/dso-page-single-feature.guard';
import { FeatureID } from '../../core/data/feature-authorization/feature-id';
import { RemoteData } from '../../core/data/remote-data';
import { Item } from '../../core/shared/item.model';
import { itemPageResolver } from '../item-page.resolver';
@Injectable({
providedIn: 'root',
})
/**
* Guard for preventing unauthorized access to certain {@link Item} pages requiring manage versions rights
*/
export class ItemPageVersionHistoryGuard extends DsoPageSingleFeatureGuard<Item> {
protected resolver: ResolveFn<RemoteData<Item>> = itemPageResolver;
constructor(protected authorizationService: AuthorizationDataService,
protected router: Router,
protected authService: AuthService) {
super(authorizationService, router, authService);
}
/**
* Check manage versions authorization rights
*/
getFeatureID(next: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<FeatureID> {
return observableOf(FeatureID.CanManageVersions);
}
}
export const itemPageVersionHistoryGuard: CanActivateFn =
dsoPageSingleFeatureGuard(
() => itemPageResolver,
() => observableOf(FeatureID.CanManageVersions),
);

View File

@@ -1,43 +1,16 @@
import { Injectable } from '@angular/core';
import {
ActivatedRouteSnapshot,
ResolveFn,
Router,
RouterStateSnapshot,
} from '@angular/router';
import {
Observable,
of as observableOf,
} from 'rxjs';
import { CanActivateFn } from '@angular/router';
import { of as observableOf } from 'rxjs';
import { AuthService } from '../../core/auth/auth.service';
import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service';
import { DsoPageSingleFeatureGuard } from '../../core/data/feature-authorization/feature-authorization-guard/dso-page-single-feature.guard';
import { dsoPageSingleFeatureGuard } from '../../core/data/feature-authorization/feature-authorization-guard/dso-page-single-feature.guard';
import { FeatureID } from '../../core/data/feature-authorization/feature-id';
import { RemoteData } from '../../core/data/remote-data';
import { Item } from '../../core/shared/item.model';
import { itemPageResolver } from '../item-page.resolver';
@Injectable({
providedIn: 'root',
})
/**
* Guard for preventing unauthorized access to certain {@link Item} pages requiring withdraw rights
*/
export class ItemPageWithdrawGuard extends DsoPageSingleFeatureGuard<Item> {
protected resolver: ResolveFn<RemoteData<Item>> = itemPageResolver;
constructor(protected authorizationService: AuthorizationDataService,
protected router: Router,
protected authService: AuthService) {
super(authorizationService, router, authService);
}
/**
* Check withdraw authorization rights
*/
getFeatureID(next: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<FeatureID> {
return observableOf(FeatureID.WithdrawItem);
}
}
export const itemPageWithdrawGuard: CanActivateFn =
dsoPageSingleFeatureGuard(
() => itemPageResolver,
() => observableOf(FeatureID.WithdrawItem),
);

View File

@@ -0,0 +1,412 @@
import { TestBed } from '@angular/core/testing';
import { TranslateModule } from '@ngx-translate/core';
import { of as observableOf } from 'rxjs';
import { v4 as uuidv4 } from 'uuid';
import { EntityTypeDataService } from '../../../core/data/entity-type-data.service';
import { ItemDataService } from '../../../core/data/item-data.service';
import { FieldChangeType } from '../../../core/data/object-updates/field-change-type.model';
import { FieldUpdate } from '../../../core/data/object-updates/field-update.model';
import { FieldUpdates } from '../../../core/data/object-updates/field-updates.model';
import {
DeleteRelationship,
RelationshipIdentifiable,
} from '../../../core/data/object-updates/object-updates.reducer';
import { ObjectUpdatesService } from '../../../core/data/object-updates/object-updates.service';
import { RelationshipDataService } from '../../../core/data/relationship-data.service';
import { Item } from '../../../core/shared/item.model';
import { ItemType } from '../../../core/shared/item-relationships/item-type.model';
import { Relationship } from '../../../core/shared/item-relationships/relationship.model';
import { RelationshipType } from '../../../core/shared/item-relationships/relationship-type.model';
import { NotificationsService } from '../../../shared/notifications/notifications.service';
import {
createFailedRemoteDataObject,
createSuccessfulRemoteDataObject,
createSuccessfulRemoteDataObject$,
} from '../../../shared/remote-data.utils';
import { EntityTypeDataServiceStub } from '../../../shared/testing/entity-type-data.service.stub';
import { ItemDataServiceStub } from '../../../shared/testing/item-data.service.stub';
import { NotificationsServiceStub } from '../../../shared/testing/notifications-service.stub';
import { ObjectUpdatesServiceStub } from '../../../shared/testing/object-updates.service.stub';
import { RelationshipDataServiceStub } from '../../../shared/testing/relationship-data.service.stub';
import { EditItemRelationshipsService } from './edit-item-relationships.service';
describe('EditItemRelationshipsService', () => {
let service: EditItemRelationshipsService;
let itemService: ItemDataServiceStub;
let objectUpdatesService: ObjectUpdatesServiceStub;
let notificationsService: NotificationsServiceStub;
let relationshipService: RelationshipDataServiceStub;
let entityTypeDataService: EntityTypeDataServiceStub;
let currentItem: Item;
let relationshipItem1: Item;
let relationshipIdentifiable1: RelationshipIdentifiable;
let relationship1: Relationship;
let relationshipItem2: Item;
let relationshipIdentifiable2: RelationshipIdentifiable;
let relationship2: Relationship;
let orgUnitType: ItemType;
let orgUnitToOrgUnitType: RelationshipType;
beforeEach(() => {
itemService = new ItemDataServiceStub();
objectUpdatesService = new ObjectUpdatesServiceStub();
notificationsService = new NotificationsServiceStub();
relationshipService = new RelationshipDataServiceStub();
entityTypeDataService = new EntityTypeDataServiceStub();
TestBed.configureTestingModule({
imports: [
TranslateModule.forRoot(),
],
providers: [
{ provide: ItemDataService, useValue: itemService },
{ provide: ObjectUpdatesService, useValue: objectUpdatesService },
{ provide: NotificationsService, useValue: notificationsService },
{ provide: RelationshipDataService, useValue: relationshipService },
{ provide: EntityTypeDataService, useValue: entityTypeDataService },
],
});
service = TestBed.inject(EditItemRelationshipsService);
});
beforeEach(() => {
currentItem = Object.assign(new Item(), {
uuid: uuidv4(),
metadata: {
'dspace.entity.type': 'OrgUnit',
},
_links: {
self: {
href: 'selfLink1',
},
},
});
relationshipItem1 = Object.assign(new Item(), {
uuid: uuidv4(),
metadata: {
'dspace.entity.type': 'OrgUnit',
},
_links: {
self: {
href: 'selfLink2',
},
},
});
relationshipIdentifiable1 = {
originalItem: currentItem,
relatedItem: relationshipItem1,
type: orgUnitToOrgUnitType,
uuid: `1-${relationshipItem1.uuid}`,
} as RelationshipIdentifiable;
relationship1 = Object.assign(new Relationship(), {
_links: {
leftItem: currentItem._links.self,
rightItem: relationshipItem1._links.self,
},
});
relationshipItem2 = Object.assign(new Item(), {
uuid: uuidv4(),
metadata: {
'dspace.entity.type': 'OrgUnit',
},
_links: {
self: {
href: 'selfLink3',
},
},
});
relationshipIdentifiable2 = {
originalItem: currentItem,
relatedItem: relationshipItem2,
type: orgUnitToOrgUnitType,
uuid: `1-${relationshipItem2.uuid}`,
} as RelationshipIdentifiable;
relationship2 = Object.assign(new Relationship(), {
_links: {
leftItem: currentItem._links.self,
rightItem: relationshipItem2._links.self,
},
});
orgUnitType = Object.assign(new ItemType(), {
id: '2',
label: 'OrgUnit',
});
orgUnitToOrgUnitType = Object.assign(new RelationshipType(), {
id: '1',
leftMaxCardinality: null,
leftMinCardinality: 0,
leftType: createSuccessfulRemoteDataObject$(orgUnitType),
leftwardType: 'isOrgUnitOfOrgUnit',
rightMaxCardinality: null,
rightMinCardinality: 0,
rightType: createSuccessfulRemoteDataObject$(orgUnitType),
rightwardType: 'isOrgUnitOfOrgUnit',
uuid: 'relationshiptype-1',
});
});
describe('submit', () => {
let fieldUpdateAddRelationship1: FieldUpdate;
let fieldUpdateRemoveRelationship2: FieldUpdate;
beforeEach(() => {
fieldUpdateAddRelationship1 = {
changeType: FieldChangeType.ADD,
field: relationshipIdentifiable1,
};
fieldUpdateRemoveRelationship2 = {
changeType: FieldChangeType.REMOVE,
field: relationshipIdentifiable2,
};
spyOn(service, 'addRelationship').withArgs(relationshipIdentifiable1).and.returnValue(createSuccessfulRemoteDataObject$(relationship1));
spyOn(service, 'deleteRelationship').withArgs(relationshipIdentifiable2 as DeleteRelationship).and.returnValue(createSuccessfulRemoteDataObject$({}));
spyOn(itemService, 'invalidateByHref').and.callThrough();
});
it('should support performing multiple relationships manipulations in one submit() call', () => {
spyOn(objectUpdatesService, 'getFieldUpdates').and.returnValue(observableOf({
[`1-${relationshipItem1.uuid}`]: fieldUpdateAddRelationship1,
[`1-${relationshipItem2.uuid}`]: fieldUpdateRemoveRelationship2,
} as FieldUpdates));
service.submit(currentItem, `/entities/orgunit/${currentItem.uuid}/edit/relationships`);
expect(service.addRelationship).toHaveBeenCalledWith(relationshipIdentifiable1);
expect(service.deleteRelationship).toHaveBeenCalledWith(relationshipIdentifiable2 as DeleteRelationship);
expect(itemService.invalidateByHref).toHaveBeenCalledWith(currentItem.self);
expect(itemService.invalidateByHref).toHaveBeenCalledWith(relationshipItem1.self);
expect(itemService.invalidateByHref).toHaveBeenCalledWith(relationshipItem2.self);
expect(notificationsService.success).toHaveBeenCalledTimes(1);
});
});
describe('deleteRelationship', () => {
beforeEach(() => {
spyOn(relationshipService, 'deleteRelationship').and.callThrough();
});
it('should pass "all" as copyVirtualMetadata when the user want to keep the data on both sides', () => {
service.deleteRelationship({
uuid: relationshipItem1.uuid,
keepLeftVirtualMetadata: true,
keepRightVirtualMetadata: true,
} as DeleteRelationship);
expect(relationshipService.deleteRelationship).toHaveBeenCalledWith(relationshipItem1.uuid, 'all', false);
});
it('should pass "left" as copyVirtualMetadata when the user only want to keep the data on the left side', () => {
service.deleteRelationship({
uuid: relationshipItem1.uuid,
keepLeftVirtualMetadata: true,
keepRightVirtualMetadata: false,
} as DeleteRelationship);
expect(relationshipService.deleteRelationship).toHaveBeenCalledWith(relationshipItem1.uuid, 'left', false);
});
it('should pass "right" as copyVirtualMetadata when the user only want to keep the data on the right side', () => {
service.deleteRelationship({
uuid: relationshipItem1.uuid,
keepLeftVirtualMetadata: false,
keepRightVirtualMetadata: true,
} as DeleteRelationship);
expect(relationshipService.deleteRelationship).toHaveBeenCalledWith(relationshipItem1.uuid, 'right', false);
});
it('should pass "none" as copyVirtualMetadata when the user doesn\'t want to keep the virtual metadata', () => {
service.deleteRelationship({
uuid: relationshipItem1.uuid,
keepLeftVirtualMetadata: false,
keepRightVirtualMetadata: false,
} as DeleteRelationship);
expect(relationshipService.deleteRelationship).toHaveBeenCalledWith(relationshipItem1.uuid, 'none', false);
});
});
describe('addRelationship', () => {
beforeEach(() => {
spyOn(relationshipService, 'addRelationship').and.callThrough();
});
it('should call the addRelationship from relationshipService correctly when original item is on the right', () => {
service.addRelationship({
originalItem: currentItem,
originalIsLeft: false,
relatedItem: relationshipItem1,
type: orgUnitToOrgUnitType,
uuid: `1-${relationshipItem1.uuid}`,
} as RelationshipIdentifiable);
expect(relationshipService.addRelationship).toHaveBeenCalledWith(orgUnitToOrgUnitType.id, relationshipItem1, currentItem, undefined, null, false);
});
it('should call the addRelationship from relationshipService correctly when original item is on the left', () => {
service.addRelationship({
originalItem: currentItem,
originalIsLeft: true,
relatedItem: relationshipItem1,
type: orgUnitToOrgUnitType,
uuid: `1-${relationshipItem1.uuid}`,
} as RelationshipIdentifiable);
expect(relationshipService.addRelationship).toHaveBeenCalledWith(orgUnitToOrgUnitType.id, currentItem, relationshipItem1, null, undefined, false);
});
});
describe('isProvidedItemTypeLeftType', () => {
it('should return true if the provided item corresponds to the left type of the relationship', (done) => {
const relationshipType = Object.assign(new RelationshipType(), {
leftType: createSuccessfulRemoteDataObject$({ id: 'leftType' }),
rightType: createSuccessfulRemoteDataObject$({ id: 'rightType' }),
});
const itemType = Object.assign(new ItemType(), { id: 'leftType' } );
const item = Object.assign(new Item(), { uuid: 'item-uuid' });
const result = service.isProvidedItemTypeLeftType(relationshipType, itemType, item);
result.subscribe((resultValue) => {
expect(resultValue).toBeTrue();
done();
});
});
it('should return false if the provided item corresponds to the right type of the relationship', (done) => {
const relationshipType = Object.assign(new RelationshipType(), {
leftType: createSuccessfulRemoteDataObject$({ id: 'leftType' }),
rightType: createSuccessfulRemoteDataObject$({ id: 'rightType' }),
});
const itemType = Object.assign(new ItemType(), { id: 'rightType' } );
const item = Object.assign(new Item(), { uuid: 'item-uuid' });
const result = service.isProvidedItemTypeLeftType(relationshipType, itemType, item);
result.subscribe((resultValue) => {
expect(resultValue).toBeFalse();
done();
});
});
it('should return undefined if the provided item corresponds does not match any of the relationship types', (done) => {
const relationshipType = Object.assign(new RelationshipType(), {
leftType: createSuccessfulRemoteDataObject$({ id: 'leftType' }),
rightType: createSuccessfulRemoteDataObject$({ id: 'rightType' }),
});
const itemType = Object.assign(new ItemType(), { id: 'something-else' } );
const item = Object.assign(new Item(), { uuid: 'item-uuid' });
const result = service.isProvidedItemTypeLeftType(relationshipType, itemType, item);
result.subscribe((resultValue) => {
expect(resultValue).toBeUndefined();
done();
});
});
});
describe('relationshipMatchesBothSameTypes', () => {
it('should return true if both left and right type of the relationship type are the same and match the provided itemtype', (done) => {
const relationshipType = Object.assign(new RelationshipType(), {
leftType: createSuccessfulRemoteDataObject$({ id: 'sameType' }),
rightType: createSuccessfulRemoteDataObject$({ id:'sameType' }),
leftwardType: 'isDepartmentOfDivision',
rightwardType: 'isDivisionOfDepartment',
});
const itemType = Object.assign(new ItemType(), { id: 'sameType' } );
const result = service.shouldDisplayBothRelationshipSides(relationshipType, itemType);
result.subscribe((resultValue) => {
expect(resultValue).toBeTrue();
done();
});
});
it('should return false if both left and right type of the relationship type are the same and match the provided itemtype but the leftwardType & rightwardType is identical', (done) => {
const relationshipType = Object.assign(new RelationshipType(), {
leftType: createSuccessfulRemoteDataObject$({ id: 'sameType' }),
rightType: createSuccessfulRemoteDataObject$({ id: 'sameType' }),
leftwardType: 'isOrgUnitOfOrgUnit',
rightwardType: 'isOrgUnitOfOrgUnit',
});
const itemType = Object.assign(new ItemType(), { id: 'sameType' });
const result = service.shouldDisplayBothRelationshipSides(relationshipType, itemType);
result.subscribe((resultValue) => {
expect(resultValue).toBeFalse();
done();
});
});
it('should return false if both left and right type of the relationship type are the same and do not match the provided itemtype', (done) => {
const relationshipType = Object.assign(new RelationshipType(), {
leftType: createSuccessfulRemoteDataObject$({ id: 'sameType' }),
rightType: createSuccessfulRemoteDataObject$({ id: 'sameType' }),
leftwardType: 'isDepartmentOfDivision',
rightwardType: 'isDivisionOfDepartment',
});
const itemType = Object.assign(new ItemType(), { id: 'something-else' } );
const result = service.shouldDisplayBothRelationshipSides(relationshipType, itemType);
result.subscribe((resultValue) => {
expect(resultValue).toBeFalse();
done();
});
});
it('should return false if both left and right type of the relationship type are different', (done) => {
const relationshipType = Object.assign(new RelationshipType(), {
leftType: createSuccessfulRemoteDataObject$({ id: 'leftType' }),
rightType: createSuccessfulRemoteDataObject$({ id: 'rightType' }),
leftwardType: 'isAuthorOfPublication',
rightwardType: 'isPublicationOfAuthor',
});
const itemType = Object.assign(new ItemType(), { id: 'leftType' } );
const result = service.shouldDisplayBothRelationshipSides(relationshipType, itemType);
result.subscribe((resultValue) => {
expect(resultValue).toBeFalse();
done();
});
});
});
describe('displayNotifications', () => {
it('should show one success notification when multiple requests succeeded', () => {
service.displayNotifications([
createSuccessfulRemoteDataObject({}),
createSuccessfulRemoteDataObject({}),
]);
expect(notificationsService.success).toHaveBeenCalledTimes(1);
});
it('should show one success notification even when some requests failed', () => {
service.displayNotifications([
createSuccessfulRemoteDataObject({}),
createFailedRemoteDataObject('Request Failed'),
createSuccessfulRemoteDataObject({}),
]);
expect(notificationsService.success).toHaveBeenCalledTimes(1);
expect(notificationsService.error).toHaveBeenCalledTimes(1);
});
it('should show a separate error notification for each failed request', () => {
service.displayNotifications([
createSuccessfulRemoteDataObject({}),
createFailedRemoteDataObject('Request Failed 1'),
createSuccessfulRemoteDataObject({}),
createFailedRemoteDataObject('Request Failed 2'),
]);
expect(notificationsService.success).toHaveBeenCalledTimes(1);
expect(notificationsService.error).toHaveBeenCalledTimes(2);
});
});
});

View File

@@ -0,0 +1,267 @@
import { Injectable } from '@angular/core';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { TranslateService } from '@ngx-translate/core';
import {
BehaviorSubject,
combineLatest as observableCombineLatest,
EMPTY,
Observable,
Subscription,
} from 'rxjs';
import {
concatMap,
map,
switchMap,
take,
toArray,
} from 'rxjs/operators';
import { EntityTypeDataService } from '../../../core/data/entity-type-data.service';
import { ItemDataService } from '../../../core/data/item-data.service';
import { FieldChangeType } from '../../../core/data/object-updates/field-change-type.model';
import { FieldUpdate } from '../../../core/data/object-updates/field-update.model';
import { FieldUpdates } from '../../../core/data/object-updates/field-updates.model';
import {
DeleteRelationship,
RelationshipIdentifiable,
} from '../../../core/data/object-updates/object-updates.reducer';
import { ObjectUpdatesService } from '../../../core/data/object-updates/object-updates.service';
import { RelationshipDataService } from '../../../core/data/relationship-data.service';
import { RemoteData } from '../../../core/data/remote-data';
import { Item } from '../../../core/shared/item.model';
import { ItemType } from '../../../core/shared/item-relationships/item-type.model';
import { Relationship } from '../../../core/shared/item-relationships/relationship.model';
import { RelationshipType } from '../../../core/shared/item-relationships/relationship-type.model';
import { NoContent } from '../../../core/shared/NoContent.model';
import {
getFirstSucceededRemoteData,
getRemoteDataPayload,
} from '../../../core/shared/operators';
import { hasValue } from '../../../shared/empty.util';
import { NotificationsService } from '../../../shared/notifications/notifications.service';
@Injectable({
providedIn: 'root',
})
export class EditItemRelationshipsService {
public notificationsPrefix = 'item.edit.relationships.notifications.';
public isSaving$: BehaviorSubject<boolean> = new BehaviorSubject(false);
constructor(
public itemService: ItemDataService,
public objectUpdatesService: ObjectUpdatesService,
public notificationsService: NotificationsService,
protected modalService: NgbModal,
public relationshipService: RelationshipDataService,
public entityTypeService: EntityTypeDataService,
public translateService: TranslateService,
) { }
/**
* Resolve the currently selected related items back to relationships and send a delete request for each of the relationships found
* Make sure the lists are refreshed afterwards and notifications are sent for success and errors
*/
public submit(item: Item, url: string): void {
this.isSaving$.next(true);
this.objectUpdatesService.getFieldUpdates(url, [], true).pipe(
map((fieldUpdates: FieldUpdates) =>
Object.values(fieldUpdates)
.filter((fieldUpdate: FieldUpdate) => hasValue(fieldUpdate))
.filter((fieldUpdate: FieldUpdate) => fieldUpdate.changeType === FieldChangeType.ADD || fieldUpdate.changeType === FieldChangeType.REMOVE),
),
take(1),
// emit each update in the array separately
switchMap((updates) => updates),
// process each update one by one, while waiting for the previous to finish
concatMap((update: FieldUpdate) => {
if (update.changeType === FieldChangeType.REMOVE) {
return this.deleteRelationship(update.field as DeleteRelationship).pipe(
take(1),
switchMap((deleteRD: RemoteData<NoContent>) => {
if (deleteRD.hasSucceeded) {
return this.itemService.invalidateByHref((update.field as DeleteRelationship).relatedItem._links.self.href).pipe(
map(() => deleteRD),
);
}
return [deleteRD];
}),
);
} else if (update.changeType === FieldChangeType.ADD) {
return this.addRelationship(update.field as RelationshipIdentifiable).pipe(
take(1),
switchMap((relationshipRD: RemoteData<Relationship>) => {
if (relationshipRD.hasSucceeded) {
// Set the newly related item to stale, so its relationships will update to include
// the new one. Only set the current item to stale at the very end so we only do it
// once
const { leftItem, rightItem } = relationshipRD.payload._links;
if (leftItem.href === item.self) {
return this.itemService.invalidateByHref(rightItem.href).pipe(
// when it's invalidated, emit the original relationshipRD for use in the pipe below
map(() => relationshipRD),
);
} else {
return this.itemService.invalidateByHref(leftItem.href).pipe(
// when it's invalidated, emit the original relationshipRD for use in the pipe below
map(() => relationshipRD),
);
}
} else {
return [relationshipRD];
}
}),
);
} else {
return EMPTY;
}
}),
toArray(),
switchMap((responses) => {
// once all relationships are made and all related items have been invalidated, invalidate
// the current item
return this.itemService.invalidateByHref(item.self).pipe(
map(() => responses),
);
}),
).subscribe((responses) => {
if (responses.length > 0) {
this.initializeOriginalFields(item, url);
this.displayNotifications(responses);
this.modalService.dismissAll();
this.isSaving$.next(false);
}
});
}
/**
* Sends all initial values of this item to the object updates service
*/
public initializeOriginalFields(item: Item, url: string): Subscription {
return this.relationshipService.getRelatedItems(item).pipe(
take(1),
).subscribe((items: Item[]) => {
this.objectUpdatesService.initialize(url, items, item.lastModified);
});
}
deleteRelationship(deleteRelationship: DeleteRelationship): Observable<RemoteData<NoContent>> {
let copyVirtualMetadata: string;
if (deleteRelationship.keepLeftVirtualMetadata && deleteRelationship.keepRightVirtualMetadata) {
copyVirtualMetadata = 'all';
} else if (deleteRelationship.keepLeftVirtualMetadata) {
copyVirtualMetadata = 'left';
} else if (deleteRelationship.keepRightVirtualMetadata) {
copyVirtualMetadata = 'right';
} else {
copyVirtualMetadata = 'none';
}
return this.relationshipService.deleteRelationship(deleteRelationship.uuid, copyVirtualMetadata, false);
}
addRelationship(addRelationship: RelationshipIdentifiable): Observable<RemoteData<Relationship>> {
let leftItem: Item;
let rightItem: Item;
let leftwardValue: string;
let rightwardValue: string;
if (addRelationship.originalIsLeft) {
leftItem = addRelationship.originalItem;
rightItem = addRelationship.relatedItem;
leftwardValue = null;
rightwardValue = addRelationship.nameVariant;
} else {
leftItem = addRelationship.relatedItem;
rightItem = addRelationship.originalItem;
leftwardValue = addRelationship.nameVariant;
rightwardValue = null;
}
return this.relationshipService.addRelationship(addRelationship.type.id, leftItem, rightItem, leftwardValue, rightwardValue, false);
}
/**
* Display notifications
* - Error notification for each failed response with their message
* - Success notification in case there's at least one successful response
* @param responses
*/
displayNotifications(responses: RemoteData<NoContent>[]): void {
const failedResponses = responses.filter((response: RemoteData<NoContent>) => response.hasFailed);
const successfulResponses = responses.filter((response: RemoteData<NoContent>) => response.hasSucceeded);
failedResponses.forEach((response: RemoteData<NoContent>) => {
this.notificationsService.error(this.getNotificationTitle('failed'), response.errorMessage);
});
if (successfulResponses.length > 0) {
this.notificationsService.success(this.getNotificationTitle('saved'), this.getNotificationContent('saved'));
}
}
isProvidedItemTypeLeftType(relationshipType: RelationshipType, itemType: ItemType, item: Item): Observable<boolean> {
return this.getRelationshipLeftAndRightType(relationshipType).pipe(
map(([leftType, rightType]: [ItemType, ItemType]) => {
if (leftType.id === itemType.id) {
return true;
}
if (rightType.id === itemType.id) {
return false;
}
// should never happen...
console.warn(`The item ${item.uuid} is not on the right or the left side of relationship type ${relationshipType.uuid}`);
return undefined;
}),
);
}
/**
* Whether both side of the relationship need to be displayed on the edit relationship page or not.
*
* @param relationshipType The relationship type
* @param itemType The item type
*/
shouldDisplayBothRelationshipSides(relationshipType: RelationshipType, itemType: ItemType): Observable<boolean> {
return this.getRelationshipLeftAndRightType(relationshipType).pipe(
map(([leftType, rightType]: [ItemType, ItemType]) => {
return leftType.id === itemType.id && rightType.id === itemType.id && relationshipType.leftwardType !== relationshipType.rightwardType;
}),
);
}
protected getRelationshipLeftAndRightType(relationshipType: RelationshipType): Observable<[ItemType, ItemType]> {
const leftType$: Observable<ItemType> = relationshipType.leftType.pipe(
getFirstSucceededRemoteData(),
getRemoteDataPayload(),
);
const rightType$: Observable<ItemType> = relationshipType.rightType.pipe(
getFirstSucceededRemoteData(),
getRemoteDataPayload(),
);
return observableCombineLatest([
leftType$,
rightType$,
]);
}
/**
* Get translated notification title
* @param key
*/
getNotificationTitle(key: string): string {
return this.translateService.instant(this.notificationsPrefix + key + '.title');
}
/**
* Get translated notification content
* @param key
*/
getNotificationContent(key: string): string {
return this.translateService.instant(this.notificationsPrefix + key + '.content');
}
}

View File

@@ -0,0 +1,31 @@
<ng-container *ngIf="shouldDisplayBothRelationshipSides$ | async">
<ds-edit-relationship-list
[url]="url"
[item]="item"
[itemType]="itemType"
[relationshipType]="relationshipType"
[hasChanges]="hasChanges"
[currentItemIsLeftItem$]="isLeftItem$"
class="d-block mb-4"
></ds-edit-relationship-list>
<ds-edit-relationship-list
[url]="url"
[item]="item"
[itemType]="itemType"
[relationshipType]="relationshipType"
[hasChanges]="hasChanges"
[currentItemIsLeftItem$]="isRightItem$"
></ds-edit-relationship-list>
</ng-container>
<ng-container *ngIf="(shouldDisplayBothRelationshipSides$ | async) === false">
<ds-edit-relationship-list
[url]="url"
[item]="item"
[itemType]="itemType"
[relationshipType]="relationshipType"
[hasChanges]="hasChanges"
[currentItemIsLeftItem$]="currentItemIsLeftItem$"
></ds-edit-relationship-list>
</ng-container>

View File

@@ -0,0 +1,122 @@
import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import {
ComponentFixture,
TestBed,
waitForAsync,
} from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { cold } from 'jasmine-marbles';
import { of as observableOf } from 'rxjs';
import { Item } from '../../../../core/shared/item.model';
import { ItemType } from '../../../../core/shared/item-relationships/item-type.model';
import { RelationshipType } from '../../../../core/shared/item-relationships/relationship-type.model';
import { createSuccessfulRemoteDataObject$ } from '../../../../shared/remote-data.utils';
import { EditItemRelationshipsService } from '../edit-item-relationships.service';
import { EditRelationshipListComponent } from '../edit-relationship-list/edit-relationship-list.component';
import { EditRelationshipListWrapperComponent } from './edit-relationship-list-wrapper.component';
describe('EditRelationshipListWrapperComponent', () => {
let editItemRelationshipsService: EditItemRelationshipsService;
let comp: EditRelationshipListWrapperComponent;
let fixture: ComponentFixture<EditRelationshipListWrapperComponent>;
const leftType = Object.assign(new ItemType(), { id: 'leftType', label: 'leftTypeString' });
const rightType = Object.assign(new ItemType(), { id: 'rightType', label: 'rightTypeString' });
const relationshipType = Object.assign(new RelationshipType(), {
id: '1',
leftMaxCardinality: null,
leftMinCardinality: 0,
leftType: createSuccessfulRemoteDataObject$(leftType),
leftwardType: 'isOrgUnitOfOrgUnit',
rightMaxCardinality: null,
rightMinCardinality: 0,
rightType: createSuccessfulRemoteDataObject$(rightType),
rightwardType: 'isOrgUnitOfOrgUnit',
uuid: 'relationshiptype-1',
});
const item = Object.assign(new Item(), { uuid: 'item-uuid' });
beforeEach(waitForAsync(() => {
editItemRelationshipsService = jasmine.createSpyObj('editItemRelationshipsService', {
isProvidedItemTypeLeftType: observableOf(true),
shouldDisplayBothRelationshipSides: observableOf(false),
});
TestBed.configureTestingModule({
imports: [
EditRelationshipListWrapperComponent,
],
providers: [
{ provide: EditItemRelationshipsService, useValue: editItemRelationshipsService },
],
schemas: [
CUSTOM_ELEMENTS_SCHEMA,
],
}).overrideComponent(EditRelationshipListWrapperComponent, {
remove: {
imports: [
EditRelationshipListComponent,
],
},
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(EditRelationshipListWrapperComponent);
comp = fixture.componentInstance;
comp.relationshipType = relationshipType;
comp.itemType = leftType;
comp.item = item;
fixture.detectChanges();
});
describe('onInit', () => {
it('should render the component', () => {
expect(comp).toBeTruthy();
});
it('should set currentItemIsLeftItem$ and bothItemsMatchType$ based on the provided relationshipType, itemType and item', () => {
expect(editItemRelationshipsService.isProvidedItemTypeLeftType).toHaveBeenCalledWith(relationshipType, leftType, item);
expect(editItemRelationshipsService.shouldDisplayBothRelationshipSides).toHaveBeenCalledWith(relationshipType, leftType);
expect(comp.currentItemIsLeftItem$.getValue()).toEqual(true);
expect(comp.shouldDisplayBothRelationshipSides$).toBeObservable(cold('(a|)', { a: false }));
});
});
describe('when the current item is left', () => {
it('should render one relationship list section', () => {
const relationshipLists = fixture.debugElement.queryAll(By.css('ds-edit-relationship-list'));
expect(relationshipLists.length).toEqual(1);
});
});
describe('when the current item is right', () => {
it('should render one relationship list section', () => {
(editItemRelationshipsService.isProvidedItemTypeLeftType as jasmine.Spy).and.returnValue(observableOf(false));
comp.ngOnInit();
fixture.detectChanges();
const relationshipLists = fixture.debugElement.queryAll(By.css('ds-edit-relationship-list'));
expect(relationshipLists.length).toEqual(1);
});
});
describe('when the current item is both left and right', () => {
it('should render two relationship list sections', () => {
(editItemRelationshipsService.shouldDisplayBothRelationshipSides as jasmine.Spy).and.returnValue(observableOf(true));
comp.ngOnInit();
fixture.detectChanges();
const relationshipLists = fixture.debugElement.queryAll(By.css('ds-edit-relationship-list'));
expect(relationshipLists.length).toEqual(2);
});
});
});

View File

@@ -0,0 +1,111 @@
import {
AsyncPipe,
NgIf,
} from '@angular/common';
import {
Component,
EventEmitter,
Input,
OnDestroy,
OnInit,
Output,
} from '@angular/core';
import {
BehaviorSubject,
Observable,
Subscription,
} from 'rxjs';
import { Item } from '../../../../core/shared/item.model';
import { ItemType } from '../../../../core/shared/item-relationships/item-type.model';
import { RelationshipType } from '../../../../core/shared/item-relationships/relationship-type.model';
import { hasValue } from '../../../../shared/empty.util';
import { EditItemRelationshipsService } from '../edit-item-relationships.service';
import { EditRelationshipListComponent } from '../edit-relationship-list/edit-relationship-list.component';
@Component({
selector: 'ds-edit-relationship-list-wrapper',
styleUrls: ['./edit-relationship-list-wrapper.component.scss'],
templateUrl: './edit-relationship-list-wrapper.component.html',
standalone: true,
imports: [
AsyncPipe,
EditRelationshipListComponent,
NgIf,
],
})
/**
* A component creating a list of editable relationships of a certain type
* The relationships are rendered as a list of related items
*/
export class EditRelationshipListWrapperComponent implements OnInit, OnDestroy {
/**
* The item to display related items for
*/
@Input() item: Item;
@Input() itemType: ItemType;
/**
* The URL to the current page
* Used to fetch updates for the current item from the store
*/
@Input() url: string;
/**
* The label of the relationship-type we're rendering a list for
*/
@Input() relationshipType: RelationshipType;
/**
* If updated information has changed
*/
@Input() hasChanges!: Observable<boolean>;
/**
* The event emmiter to submit the new information
*/
@Output() submitModal: EventEmitter<void> = new EventEmitter();
/**
* Observable that emits true if {@link itemType} is on the left-hand side of {@link relationshipType},
* false if it is on the right-hand side and undefined in the rare case that it is on neither side.
*/
currentItemIsLeftItem$: BehaviorSubject<boolean> = new BehaviorSubject(undefined);
isLeftItem$ = new BehaviorSubject(true);
isRightItem$ = new BehaviorSubject(false);
shouldDisplayBothRelationshipSides$: Observable<boolean>;
/**
* Array to track all subscriptions and unsubscribe them onDestroy
* @type {Array}
*/
private subs: Subscription[] = [];
constructor(
protected editItemRelationshipsService: EditItemRelationshipsService,
) {
}
ngOnInit(): void {
this.subs.push(this.editItemRelationshipsService.isProvidedItemTypeLeftType(this.relationshipType, this.itemType, this.item)
.subscribe((nextValue: boolean) => {
this.currentItemIsLeftItem$.next(nextValue);
}));
this.shouldDisplayBothRelationshipSides$ = this.editItemRelationshipsService.shouldDisplayBothRelationshipSides(this.relationshipType, this.itemType);
}
ngOnDestroy(): void {
this.subs
.filter((subscription) => hasValue(subscription))
.forEach((subscription) => subscription.unsubscribe());
}
}

View File

@@ -1,5 +1,5 @@
<h2 class="h4">
{{getRelationshipMessageKey$ | async | translate}}
{{relationshipMessageKey$ | async | translate}}
<button class="ml-2 btn btn-success" [disabled]="(hasChanges | async)" (click)="openLookup()">
<i class="fas fa-plus"></i>
<span class="d-none d-sm-inline">&nbsp;{{"item.edit.relationships.edit.buttons.add" | translate}}</span>

View File

@@ -10,19 +10,20 @@ import {
import { By } from '@angular/platform-browser';
import {
ActivatedRoute,
Router,
RouterModule,
} from '@angular/router';
import { provideMockStore } from '@ngrx/store/testing';
import { TranslateModule } from '@ngx-translate/core';
import { of as observableOf } from 'rxjs';
import { AuthRequestService } from 'src/app/core/auth/auth-request.service';
import { CookieService } from 'src/app/core/services/cookie.service';
import { HardRedirectService } from 'src/app/core/services/hard-redirect.service';
import { ActivatedRouteStub } from 'src/app/shared/testing/active-router.stub';
import { AuthRequestServiceStub } from 'src/app/shared/testing/auth-request-service.stub';
import { cold } from 'jasmine-marbles';
import {
BehaviorSubject,
of as observableOf,
} from 'rxjs';
import { APP_CONFIG } from '../../../../../config/app-config.interface';
import { environment } from '../../../../../environments/environment.test';
import { REQUEST } from '../../../../../express.tokens';
import { AuthRequestService } from '../../../../core/auth/auth-request.service';
import { LinkService } from '../../../../core/cache/builders/link.service';
import { ConfigurationDataService } from '../../../../core/data/configuration-data.service';
import { FieldChangeType } from '../../../../core/data/object-updates/field-change-type.model';
@@ -31,6 +32,8 @@ import { RelationshipDataService } from '../../../../core/data/relationship-data
import { RelationshipTypeDataService } from '../../../../core/data/relationship-type-data.service';
import { GroupDataService } from '../../../../core/eperson/group-data.service';
import { PaginationService } from '../../../../core/pagination/pagination.service';
import { CookieService } from '../../../../core/services/cookie.service';
import { HardRedirectService } from '../../../../core/services/hard-redirect.service';
import { LinkHeadService } from '../../../../core/services/link-head.service';
import { ConfigurationProperty } from '../../../../core/shared/configuration-property.model';
import { Item } from '../../../../core/shared/item.model';
@@ -40,54 +43,60 @@ import { RelationshipType } from '../../../../core/shared/item-relationships/rel
import { SearchConfigurationService } from '../../../../core/shared/search/search-configuration.service';
import { XSRFService } from '../../../../core/xsrf/xsrf.service';
import { HostWindowService } from '../../../../shared/host-window.service';
import { RouterMock } from '../../../../shared/mocks/router.mock';
import { SelectableListService } from '../../../../shared/object-list/selectable-list/selectable-list.service';
import { PaginationComponent } from '../../../../shared/pagination/pagination.component';
import { PaginationComponentOptions } from '../../../../shared/pagination/pagination-component-options.model';
import { createSuccessfulRemoteDataObject$ } from '../../../../shared/remote-data.utils';
import { ActivatedRouteStub } from '../../../../shared/testing/active-router.stub';
import { AuthRequestServiceStub } from '../../../../shared/testing/auth-request-service.stub';
import { EditItemRelationshipsServiceStub } from '../../../../shared/testing/edit-item-relationships.service.stub';
import { HostWindowServiceStub } from '../../../../shared/testing/host-window-service.stub';
import { PaginationServiceStub } from '../../../../shared/testing/pagination-service.stub';
import { SearchConfigurationServiceStub } from '../../../../shared/testing/search-configuration-service.stub';
import { createPaginatedList } from '../../../../shared/testing/utils.test';
import { EditItemRelationshipsService } from '../edit-item-relationships.service';
import { EditRelationshipListComponent } from './edit-relationship-list.component';
let comp: EditRelationshipListComponent;
let fixture: ComponentFixture<EditRelationshipListComponent>;
let de: DebugElement;
let linkService;
let objectUpdatesService;
let relationshipService;
let selectableListService;
let paginationService;
let hostWindowService;
let hardRedirectService;
const relationshipTypeService = {};
const url = 'http://test-url.com/test-url';
let item;
let entityType;
let relatedEntityType;
let author1;
let author2;
let fieldUpdate1;
let fieldUpdate2;
let relationships;
let relationshipType;
let paginationOptions;
describe('EditRelationshipListComponent', () => {
let comp: EditRelationshipListComponent;
let fixture: ComponentFixture<EditRelationshipListComponent>;
let de: DebugElement;
let linkService;
let objectUpdatesService;
let relationshipService;
let selectableListService;
let paginationService: PaginationServiceStub;
let hostWindowService: HostWindowServiceStub;
let hardRedirectService;
const relationshipTypeService = {};
let editItemRelationshipsService: EditItemRelationshipsServiceStub;
const url = 'http://test-url.com/test-url';
let itemLeft: Item;
let entityTypeLeft: ItemType;
let entityTypeRight: ItemType;
let itemRight1: Item;
let itemRight2: Item;
let fieldUpdate1;
let fieldUpdate2;
let relationships: Relationship[];
let relationshipType: RelationshipType;
let paginationOptions: PaginationComponentOptions;
let currentItemIsLeftItem$ = new BehaviorSubject<boolean>(true);
const resetComponent = () => {
fixture = TestBed.createComponent(EditRelationshipListComponent);
comp = fixture.componentInstance;
de = fixture.debugElement;
comp.item = item;
comp.itemType = entityType;
comp.item = itemLeft;
comp.itemType = entityTypeLeft;
comp.url = url;
comp.relationshipType = relationshipType;
comp.hasChanges = observableOf(false);
comp.currentItemIsLeftItem$ = currentItemIsLeftItem$;
fixture.detectChanges();
};
@@ -101,29 +110,26 @@ describe('EditRelationshipListComponent', () => {
},
};
hardRedirectService = jasmine.createSpyObj('hardRedirectService', ['redirect']);
beforeEach(waitForAsync(() => {
entityType = Object.assign(new ItemType(), {
id: 'Publication',
uuid: 'Publication',
label: 'Publication',
function init(leftType: string, rightType: string): void {
entityTypeLeft = Object.assign(new ItemType(), {
id: leftType,
uuid: leftType,
label: leftType,
});
relatedEntityType = Object.assign(new ItemType(), {
id: 'Author',
uuid: 'Author',
label: 'Author',
entityTypeRight = Object.assign(new ItemType(), {
id: rightType,
uuid: rightType,
label: rightType,
});
relationshipType = Object.assign(new RelationshipType(), {
id: '1',
uuid: '1',
leftType: createSuccessfulRemoteDataObject$(entityType),
rightType: createSuccessfulRemoteDataObject$(relatedEntityType),
leftwardType: 'isAuthorOfPublication',
rightwardType: 'isPublicationOfAuthor',
leftType: createSuccessfulRemoteDataObject$(entityTypeLeft),
rightType: createSuccessfulRemoteDataObject$(entityTypeRight),
leftwardType: `is${rightType}Of${leftType}`,
rightwardType: `is${leftType}Of${rightType}`,
});
paginationOptions = Object.assign(new PaginationComponentOptions(), {
@@ -132,13 +138,13 @@ describe('EditRelationshipListComponent', () => {
currentPage: 1,
});
author1 = Object.assign(new Item(), {
id: 'author1',
uuid: 'author1',
itemRight1 = Object.assign(new Item(), {
id: `${rightType}-1`,
uuid: `${rightType}-1`,
});
author2 = Object.assign(new Item(), {
id: 'author2',
uuid: 'author2',
itemRight2 = Object.assign(new Item(), {
id: `${rightType}-2`,
uuid: `${rightType}-2`,
});
relationships = [
@@ -147,25 +153,25 @@ describe('EditRelationshipListComponent', () => {
id: '2',
uuid: '2',
relationshipType: createSuccessfulRemoteDataObject$(relationshipType),
leftItem: createSuccessfulRemoteDataObject$(item),
rightItem: createSuccessfulRemoteDataObject$(author1),
leftItem: createSuccessfulRemoteDataObject$(itemLeft),
rightItem: createSuccessfulRemoteDataObject$(itemRight1),
}),
Object.assign(new Relationship(), {
self: url + '/3',
id: '3',
uuid: '3',
relationshipType: createSuccessfulRemoteDataObject$(relationshipType),
leftItem: createSuccessfulRemoteDataObject$(item),
rightItem: createSuccessfulRemoteDataObject$(author2),
leftItem: createSuccessfulRemoteDataObject$(itemLeft),
rightItem: createSuccessfulRemoteDataObject$(itemRight2),
}),
];
item = Object.assign(new Item(), {
itemLeft = Object.assign(new Item(), {
_links: {
self: { href: 'fake-item-url/publication' },
},
id: 'publication',
uuid: 'publication',
id: `1-${leftType}`,
uuid: `1-${leftType}`,
relationships: createSuccessfulRemoteDataObject$(createPaginatedList(relationships)),
});
@@ -192,12 +198,15 @@ describe('EditRelationshipListComponent', () => {
[relationships[0].uuid]: fieldUpdate1,
[relationships[1].uuid]: fieldUpdate2,
}),
// eslint-disable-next-line @typescript-eslint/no-empty-function
initialize: () => {
},
},
);
relationshipService = jasmine.createSpyObj('relationshipService',
{
getRelatedItemsByLabel: createSuccessfulRemoteDataObject$(createPaginatedList([author1, author2])),
getRelatedItemsByLabel: createSuccessfulRemoteDataObject$(createPaginatedList([itemRight1, itemRight2])),
getItemRelationshipsByLabel: createSuccessfulRemoteDataObject$(createPaginatedList(relationships)),
isLeftItem: observableOf(true),
},
@@ -233,14 +242,14 @@ describe('EditRelationshipListComponent', () => {
})),
});
const environmentUseThumbs = {
browseBy: {
showThumbnails: true,
},
};
editItemRelationshipsService = new EditItemRelationshipsServiceStub();
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot(), EditRelationshipListComponent],
imports: [
EditRelationshipListComponent,
RouterModule.forRoot([]),
TranslateModule.forRoot(),
],
providers: [
provideMockStore({ initialState }),
{ provide: ObjectUpdatesService, useValue: objectUpdatesService },
@@ -251,15 +260,15 @@ describe('EditRelationshipListComponent', () => {
{ provide: HostWindowService, useValue: hostWindowService },
{ provide: RelationshipTypeDataService, useValue: relationshipTypeService },
{ provide: GroupDataService, useValue: groupDataService },
{ provide: Router, useValue: new RouterMock() },
{ provide: LinkHeadService, useValue: linkHeadService },
{ provide: ConfigurationDataService, useValue: configurationDataService },
{ provide: SearchConfigurationService, useValue: new SearchConfigurationServiceStub() },
{ provide: EditItemRelationshipsService, useValue: editItemRelationshipsService },
{ provide: ActivatedRoute, useValue: new ActivatedRouteStub() },
{ provide: AuthRequestService, useValue: new AuthRequestServiceStub() },
{ provide: HardRedirectService, useValue: hardRedirectService },
{ provide: XSRFService, useValue: {} },
{ provide: APP_CONFIG, useValue: environmentUseThumbs },
{ provide: APP_CONFIG, useValue: environment },
{ provide: REQUEST, useValue: {} },
CookieService,
], schemas: [
@@ -268,7 +277,10 @@ describe('EditRelationshipListComponent', () => {
}).compileComponents();
resetComponent();
}));
}
describe('Publication - Author relationship', () => {
beforeEach(waitForAsync(() => init('Publication', 'Author')));
describe('changeType is REMOVE', () => {
beforeEach(() => {
@@ -316,11 +328,12 @@ describe('EditRelationshipListComponent', () => {
relationshipType = Object.assign(new RelationshipType(), {
id: '1',
uuid: '1',
leftType: createSuccessfulRemoteDataObject$(entityType), // publication
rightType: createSuccessfulRemoteDataObject$(relatedEntityType), // author
leftType: createSuccessfulRemoteDataObject$(entityTypeLeft), // publication
rightType: createSuccessfulRemoteDataObject$(entityTypeRight), // author
leftwardType: 'isAuthorOfPublication',
rightwardType: 'isPublicationOfAuthor',
});
currentItemIsLeftItem$ = new BehaviorSubject<boolean>(true);
relationshipService.getItemRelationshipsByLabel.calls.reset();
resetComponent();
});
@@ -340,11 +353,12 @@ describe('EditRelationshipListComponent', () => {
relationshipType = Object.assign(new RelationshipType(), {
id: '1',
uuid: '1',
leftType: createSuccessfulRemoteDataObject$(relatedEntityType), // author
rightType: createSuccessfulRemoteDataObject$(entityType), // publication
leftType: createSuccessfulRemoteDataObject$(entityTypeRight), // author
rightType: createSuccessfulRemoteDataObject$(entityTypeLeft), // publication
leftwardType: 'isPublicationOfAuthor',
rightwardType: 'isAuthorOfPublication',
});
currentItemIsLeftItem$ = new BehaviorSubject<boolean>(false);
relationshipService.getItemRelationshipsByLabel.calls.reset();
resetComponent();
});
@@ -377,5 +391,15 @@ describe('EditRelationshipListComponent', () => {
});
});
});
describe('OrgUnit - OrgUnit relationship', () => {
beforeEach(waitForAsync(() => init('OrgUnit', 'OrgUnit')));
it('should emit the relatedEntityType$ even for same entity relationships', () => {
expect(comp.relatedEntityType$).toBeObservable(cold('(a|)', {
a: entityTypeRight,
}));
});
});
});

Some files were not shown because too many files have changed in this diff Show More