Compare commits

...

2 Commits

Author SHA1 Message Date
Tim Donohue
01cc3ab473 Merge pull request #4724 from im-shubham-vish/fix-9814-backport-8x
[Port dspace-8_x] Fix for #8916 #8917 #8918 to 8.x: The Community Administrator should not be able to view all communities/collections in the create/edit community and collection sections (Previous PR #9814 #4639)
2025-10-02 14:52:43 -05:00
im-shubham-vish
7d0379283d Backport [#9814] Fix Only show authorized communities/collections in selector to 8x 2025-09-18 11:39:36 +05:30
20 changed files with 299 additions and 27 deletions

View File

@@ -77,11 +77,11 @@ export class CollectionDataService extends ComColDataService<Collection> {
* requested after the response becomes stale
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which
* {@link HALLink}s should be automatically resolved
* @param searchHref The backend search endpoint to use (default to submit)
* @return Observable<RemoteData<PaginatedList<Collection>>>
* collection list
*/
getAuthorizedCollection(query: string, options: FindListOptions = {}, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig<Collection>[]): Observable<RemoteData<PaginatedList<Collection>>> {
const searchHref = 'findSubmitAuthorized';
getAuthorizedCollection(query: string, options: FindListOptions = {}, useCachedVersionIfAvailable = true, reRequestOnStale = true, searchHref: string = 'findSubmitAuthorized', ...linksToFollow: FollowLinkConfig<Collection>[]): Observable<RemoteData<PaginatedList<Collection>>> {
options = Object.assign({}, options, {
searchParams: [new RequestParam('query', query)],
});

View File

@@ -11,9 +11,11 @@ import { isNotEmpty } from '../../shared/empty.util';
import { NotificationsService } from '../../shared/notifications/notifications.service';
import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
import { RequestParam } from '../cache/models/request-param.model';
import { ObjectCacheService } from '../cache/object-cache.service';
import { Community } from '../shared/community.model';
import { HALEndpointService } from '../shared/hal-endpoint.service';
import { getAllCompletedRemoteData } from '../shared/operators';
import { BitstreamDataService } from './bitstream-data.service';
import { ComColDataService } from './comcol-data.service';
import { DSOChangeAnalyzer } from './dso-change-analyzer.service';
@@ -38,6 +40,32 @@ export class CommunityDataService extends ComColDataService<Community> {
super('communities', requestService, rdbService, objectCache, halService, comparator, notificationsService, bitstreamDataService);
}
/**
* Get all communities the user is authorized to submit to
*
* @param query limit the returned community to those with metadata values
* matching the query terms.
* @param options The [[FindListOptions]] object
* @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
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which
* {@link HALLink}s should be automatically resolved
* @return Observable<RemoteData<PaginatedList<Community>>>
* community list
*/
getAuthorizedCommunity(query: string, options: FindListOptions = {}, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig<Community>[]): Observable<RemoteData<PaginatedList<Community>>> {
const searchHref = 'findAdminAuthorized';
options = Object.assign({}, options, {
searchParams: [new RequestParam('query', query)],
});
return this.searchBy(searchHref, options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow).pipe(
getAllCompletedRemoteData(),
);
}
// this method is overridden in order to make it public
getEndpoint() {
return this.halService.getEndpoint(this.linkPath);

View File

@@ -141,6 +141,12 @@ export class CollectionDropdownComponent implements OnInit, OnDestroy {
*/
@Input() entityType: string;
/**
* Search endpoint to use for finding authorized collections.
* Defaults to 'findSubmitAuthorized', but can be overridden (e.g. to 'findAdminAuthorized')
*/
@Input() searchHref = 'findSubmitAuthorized';
/**
* Emit to notify whether search is complete
*/
@@ -249,7 +255,7 @@ export class CollectionDropdownComponent implements OnInit, OnDestroy {
followLink('parentCommunity'));
} else {
searchListService$ = this.collectionDataService
.getAuthorizedCollection(query, findOptions, true, true, followLink('parentCommunity'));
.getAuthorizedCollection(query, findOptions, true, true, this.searchHref, followLink('parentCommunity'));
}
this.searchListCollection$ = searchListService$.pipe(
getFirstCompletedRemoteData(),

View File

@@ -83,5 +83,19 @@ describe('AuthorizedCollectionSelectorComponent', () => {
});
});
});
describe('when using searchHref', () => {
it('should call getAuthorizedCollection with "findAdminAuthorized" when overridden', (done) => {
component.searchHref = 'findAdminAuthorized';
component.search('', 1).subscribe(() => {
expect(collectionService.getAuthorizedCollection).toHaveBeenCalledWith(
'', jasmine.any(Object), true, false, 'findAdminAuthorized', jasmine.anything(),
);
done();
});
});
});
});
});

View File

@@ -58,6 +58,12 @@ export class AuthorizedCollectionSelectorComponent extends DSOSelectorComponent
*/
@Input() entityType: string;
/**
* Search endpoint to use for finding authorized collections.
* Defaults to 'findSubmitAuthorized', but can be overridden (e.g. to 'findAdminAuthorized')
*/
@Input() searchHref = 'findSubmitAuthorized';
constructor(
protected searchService: SearchService,
protected collectionDataService: CollectionDataService,
@@ -96,7 +102,7 @@ export class AuthorizedCollectionSelectorComponent extends DSOSelectorComponent
findOptions);
} else {
searchListService$ = this.collectionDataService
.getAuthorizedCollection(query, findOptions, useCache, false, followLink('parentCommunity'));
.getAuthorizedCollection(query, findOptions, useCache, false, this.searchHref, followLink('parentCommunity'));
}
return searchListService$.pipe(
getFirstCompletedRemoteData(),

View File

@@ -0,0 +1,71 @@
import { NO_ERRORS_SCHEMA } from '@angular/core';
import {
ComponentFixture,
TestBed,
waitForAsync,
} from '@angular/core/testing';
import { RouterTestingModule } from '@angular/router/testing';
import { TranslateModule } from '@ngx-translate/core';
import { CommunityDataService } from '../../../../core/data/community-data.service';
import { Community } from '../../../../core/shared/community.model';
import { DSpaceObjectType } from '../../../../core/shared/dspace-object-type.model';
import { SearchService } from '../../../../core/shared/search/search.service';
import { ThemedLoadingComponent } from '../../../loading/themed-loading.component';
import { NotificationsService } from '../../../notifications/notifications.service';
import { ListableObjectComponentLoaderComponent } from '../../../object-collection/shared/listable-object/listable-object-component-loader.component';
import { createSuccessfulRemoteDataObject$ } from '../../../remote-data.utils';
import { createPaginatedList } from '../../../testing/utils.test';
import { VarDirective } from '../../../utils/var.directive';
import { AuthorizedCommunitySelectorComponent } from './authorized-community-selector.component';
describe('AuthorizedCommunitySelectorComponent', () => {
let component: AuthorizedCommunitySelectorComponent;
let fixture: ComponentFixture<AuthorizedCommunitySelectorComponent>;
let communityService;
let community;
let notificationsService: NotificationsService;
beforeEach(waitForAsync(() => {
community = Object.assign(new Community(), {
id: 'authorized-community',
});
communityService = jasmine.createSpyObj('communityService', {
getAuthorizedCommunity: createSuccessfulRemoteDataObject$(createPaginatedList([community])),
});
notificationsService = jasmine.createSpyObj('notificationsService', ['error']);
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([]), AuthorizedCommunitySelectorComponent, VarDirective],
providers: [
{ provide: SearchService, useValue: {} },
{ provide: CommunityDataService, useValue: communityService },
{ provide: NotificationsService, useValue: notificationsService },
],
schemas: [NO_ERRORS_SCHEMA],
})
.overrideComponent(AuthorizedCommunitySelectorComponent, {
remove: { imports: [ListableObjectComponentLoaderComponent, ThemedLoadingComponent] },
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(AuthorizedCommunitySelectorComponent);
component = fixture.componentInstance;
component.types = [DSpaceObjectType.COMMUNITY];
fixture.detectChanges();
});
describe('search', () => {
it('should call getAuthorizedCommunity and return the authorized community in a SearchResult', (done) => {
component.search('', 1).subscribe((resultRD) => {
expect(communityService.getAuthorizedCommunity).toHaveBeenCalled();
expect(resultRD.payload.page.length).toEqual(1);
expect(resultRD.payload.page[0].indexableObject).toEqual(community);
done();
});
});
});
});

View File

@@ -0,0 +1,109 @@
import {
AsyncPipe,
CommonModule,
NgClass,
NgIf,
} from '@angular/common';
import { Component } from '@angular/core';
import {
FormsModule,
ReactiveFormsModule,
} from '@angular/forms';
import {
TranslateModule,
TranslateService,
} from '@ngx-translate/core';
import { InfiniteScrollModule } from 'ngx-infinite-scroll';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { DSONameService } from '../../../../core/breadcrumbs/dso-name.service';
import { CommunityDataService } from '../../../../core/data/community-data.service';
import { FindListOptions } from '../../../../core/data/find-list-options.model';
import {
buildPaginatedList,
PaginatedList,
} from '../../../../core/data/paginated-list.model';
import { RemoteData } from '../../../../core/data/remote-data';
import { Community } from '../../../../core/shared/community.model';
import { DSpaceObject } from '../../../../core/shared/dspace-object.model';
import { getFirstCompletedRemoteData } from '../../../../core/shared/operators';
import { SearchService } from '../../../../core/shared/search/search.service';
import { hasValue } from '../../../empty.util';
import { HoverClassDirective } from '../../../hover-class.directive';
import { ThemedLoadingComponent } from '../../../loading/themed-loading.component';
import { NotificationsService } from '../../../notifications/notifications.service';
import { CommunitySearchResult } from '../../../object-collection/shared/community-search-result.model';
import { ListableObjectComponentLoaderComponent } from '../../../object-collection/shared/listable-object/listable-object-component-loader.component';
import { SearchResult } from '../../../search/models/search-result.model';
import { followLink } from '../../../utils/follow-link-config.model';
import { DSOSelectorComponent } from '../dso-selector.component';
@Component({
selector: 'ds-authorized-community-selector',
styleUrls: ['../dso-selector.component.scss'],
templateUrl: '../dso-selector.component.html',
standalone: true,
imports: [
AsyncPipe,
CommonModule,
FormsModule,
HoverClassDirective,
InfiniteScrollModule,
ListableObjectComponentLoaderComponent,
NgClass,
NgIf,
ReactiveFormsModule,
ThemedLoadingComponent,
TranslateModule,
],
})
/**
* Component rendering a list of communities to select from
*/
export class AuthorizedCommunitySelectorComponent extends DSOSelectorComponent {
/**
* If present this value is used to filter community list by entity type
*/
constructor(
protected searchService: SearchService,
protected communityDataService: CommunityDataService,
protected notifcationsService: NotificationsService,
protected translate: TranslateService,
protected dsoNameService: DSONameService,
) {
super(searchService, notifcationsService, translate, dsoNameService);
}
/**
* Get a query to send for retrieving the current DSO
*/
getCurrentDSOQuery(): string {
return this.currentDSOId;
}
/**
* Perform a search for authorized communities with the current query and page
* @param query Query to search objects for
* @param page Page to retrieve
* @param useCache Whether or not to use the cache
*/
search(query: string, page: number, useCache: boolean = true): Observable<RemoteData<PaginatedList<SearchResult<DSpaceObject>>>> {
let searchListService$: Observable<RemoteData<PaginatedList<Community>>> = null;
const findOptions: FindListOptions = {
currentPage: page,
elementsPerPage: this.defaultPagination.pageSize,
};
searchListService$ = this.communityDataService
.getAuthorizedCommunity(query, findOptions, useCache, false, followLink('parentCommunity'));
return searchListService$.pipe(
getFirstCompletedRemoteData(),
map((rd) => Object.assign(new RemoteData(null, null, null, null), rd, {
payload: hasValue(rd.payload) ? buildPaginatedList(rd.payload.pageInfo, rd.payload.page.map((col) => Object.assign(new CommunitySearchResult(), { indexableObject: col }))) : null,
})),
);
}
}

View File

@@ -0,0 +1,12 @@
<div>
<div class="modal-header">{{'dso-selector.'+ action + '.' + objectType.toString().toLowerCase() + '.head' | translate}}
<button type="button" class="btn-close" (click)="close()" aria-label="Close">
</button>
</div>
<div class="modal-body">
<span *ngIf="header" class="h5 px-2">{{header | translate}}</span>
<ds-authorized-community-selector [currentDSOId]="dsoRD?.payload.uuid"
[types]="selectorTypes"
(onSelect)="selectObject($event)"></ds-authorized-community-selector>
</div>
</div>

View File

@@ -18,7 +18,7 @@ import { Community } from '../../../../core/shared/community.model';
import { MetadataValue } from '../../../../core/shared/metadata.models';
import { createSuccessfulRemoteDataObject } from '../../../remote-data.utils';
import { RouterStub } from '../../../testing/router.stub';
import { DSOSelectorComponent } from '../../dso-selector/dso-selector.component';
import { AuthorizedCommunitySelectorComponent } from '../../dso-selector/authorized-community-selector/authorized-community-selector.component';
import { CreateCollectionParentSelectorComponent } from './create-collection-parent-selector.component';
describe('CreateCollectionParentSelectorComponent', () => {
@@ -64,7 +64,7 @@ describe('CreateCollectionParentSelectorComponent', () => {
schemas: [NO_ERRORS_SCHEMA],
})
.overrideComponent(CreateCollectionParentSelectorComponent, {
remove: { imports: [DSOSelectorComponent] },
remove: { imports: [AuthorizedCommunitySelectorComponent] },
})
.compileComponents();

View File

@@ -22,7 +22,7 @@ import {
} from '../../../../core/cache/models/sort-options.model';
import { DSpaceObject } from '../../../../core/shared/dspace-object.model';
import { DSpaceObjectType } from '../../../../core/shared/dspace-object-type.model';
import { DSOSelectorComponent } from '../../dso-selector/dso-selector.component';
import { AuthorizedCommunitySelectorComponent } from '../../dso-selector/authorized-community-selector/authorized-community-selector.component';
import {
DSOSelectorModalWrapperComponent,
SelectorActionType,
@@ -34,9 +34,9 @@ import {
@Component({
selector: 'ds-base-create-collection-parent-selector',
templateUrl: '../dso-selector-modal-wrapper.component.html',
templateUrl: './create-collection-parent-selector.component.html',
standalone: true,
imports: [NgIf, DSOSelectorComponent, TranslateModule],
imports: [NgIf, AuthorizedCommunitySelectorComponent, TranslateModule],
})
export class CreateCollectionParentSelectorComponent extends DSOSelectorModalWrapperComponent implements OnInit {
objectType = DSpaceObjectType.COLLECTION;

View File

@@ -15,6 +15,8 @@
</div>
</div>
<span class="h5 px-2">{{'dso-selector.create.community.sub-level' | translate}}</span>
<ds-dso-selector [currentDSOId]="dsoRD?.payload.uuid" [types]="selectorTypes" [sort]="defaultSort" (onSelect)="selectObject($event)"></ds-dso-selector>
<ds-authorized-community-selector [currentDSOId]="dsoRD?.payload.uuid"
[types]="selectorTypes"
(onSelect)="selectObject($event)"></ds-authorized-community-selector>
</div>
</div>

View File

@@ -21,7 +21,7 @@ import { Community } from '../../../../core/shared/community.model';
import { MetadataValue } from '../../../../core/shared/metadata.models';
import { createSuccessfulRemoteDataObject } from '../../../remote-data.utils';
import { RouterStub } from '../../../testing/router.stub';
import { DSOSelectorComponent } from '../../dso-selector/dso-selector.component';
import { AuthorizedCommunitySelectorComponent } from '../../dso-selector/authorized-community-selector/authorized-community-selector.component';
import { CreateCommunityParentSelectorComponent } from './create-community-parent-selector.component';
describe('CreateCommunityParentSelectorComponent', () => {
@@ -69,7 +69,7 @@ describe('CreateCommunityParentSelectorComponent', () => {
schemas: [NO_ERRORS_SCHEMA],
})
.overrideComponent(CreateCommunityParentSelectorComponent, {
remove: { imports: [DSOSelectorComponent] },
remove: { imports: [AuthorizedCommunitySelectorComponent] },
})
.compileComponents();

View File

@@ -29,7 +29,7 @@ import { FeatureID } from '../../../../core/data/feature-authorization/feature-i
import { DSpaceObject } from '../../../../core/shared/dspace-object.model';
import { DSpaceObjectType } from '../../../../core/shared/dspace-object-type.model';
import { hasValue } from '../../../empty.util';
import { DSOSelectorComponent } from '../../dso-selector/dso-selector.component';
import { AuthorizedCommunitySelectorComponent } from '../../dso-selector/authorized-community-selector/authorized-community-selector.component';
import {
DSOSelectorModalWrapperComponent,
SelectorActionType,
@@ -49,7 +49,7 @@ import {
standalone: true,
imports: [
AsyncPipe,
DSOSelectorComponent,
AuthorizedCommunitySelectorComponent,
NgIf,
TranslateModule,
],
@@ -66,7 +66,6 @@ export class CreateCommunityParentSelectorComponent extends DSOSelectorModalWrap
}
ngOnInit() {
super.ngOnInit();
this.isAdmin$ = this.authorizationService.isAuthorized(FeatureID.AdministratorOf);
}

View File

@@ -0,0 +1,13 @@
<div>
<div class="modal-header">{{'dso-selector.'+ action + '.' + objectType.toString().toLowerCase() + '.head' | translate}}
<button type="button" class="btn-close" (click)="close()" aria-label="Close">
</button>
</div>
<div class="modal-body">
<span *ngIf="header" class="h5 px-2">{{header | translate}}</span>
<ds-authorized-collection-selector [currentDSOId]="dsoRD?.payload.uuid"
[types]="selectorTypes"
searchHref="findAdminAuthorized"
(onSelect)="selectObject($event)"></ds-authorized-collection-selector>
</div>
</div>

View File

@@ -18,7 +18,7 @@ import { Collection } from '../../../../core/shared/collection.model';
import { MetadataValue } from '../../../../core/shared/metadata.models';
import { createSuccessfulRemoteDataObject } from '../../../remote-data.utils';
import { RouterStub } from '../../../testing/router.stub';
import { DSOSelectorComponent } from '../../dso-selector/dso-selector.component';
import { AuthorizedCollectionSelectorComponent } from '../../dso-selector/authorized-collection-selector/authorized-collection-selector.component';
import { EditCollectionSelectorComponent } from './edit-collection-selector.component';
describe('EditCollectionSelectorComponent', () => {
@@ -64,7 +64,7 @@ describe('EditCollectionSelectorComponent', () => {
})
.overrideComponent(EditCollectionSelectorComponent, {
remove: {
imports: [DSOSelectorComponent],
imports: [AuthorizedCollectionSelectorComponent],
},
})
.compileComponents();

View File

@@ -18,7 +18,7 @@ import {
} from '../../../../core/cache/models/sort-options.model';
import { DSpaceObject } from '../../../../core/shared/dspace-object.model';
import { DSpaceObjectType } from '../../../../core/shared/dspace-object-type.model';
import { DSOSelectorComponent } from '../../dso-selector/dso-selector.component';
import { AuthorizedCollectionSelectorComponent } from '../../dso-selector/authorized-collection-selector/authorized-collection-selector.component';
import {
DSOSelectorModalWrapperComponent,
SelectorActionType,
@@ -31,9 +31,9 @@ import {
@Component({
selector: 'ds-base-edit-collection-selector',
templateUrl: '../dso-selector-modal-wrapper.component.html',
templateUrl: './edit-collection-selector.component.html',
standalone: true,
imports: [NgIf, DSOSelectorComponent, TranslateModule],
imports: [NgIf, AuthorizedCollectionSelectorComponent, TranslateModule],
})
export class EditCollectionSelectorComponent extends DSOSelectorModalWrapperComponent implements OnInit {
objectType = DSpaceObjectType.COLLECTION;

View File

@@ -0,0 +1,12 @@
<div>
<div class="modal-header">{{'dso-selector.'+ action + '.' + objectType.toString().toLowerCase() + '.head' | translate}}
<button type="button" class="btn-close" (click)="close()" aria-label="Close">
</button>
</div>
<div class="modal-body">
<span *ngIf="header" class="h5 px-2">{{header | translate}}</span>
<ds-authorized-community-selector [currentDSOId]="dsoRD?.payload.uuid"
[types]="selectorTypes"
(onSelect)="selectObject($event)"></ds-authorized-community-selector>
</div>
</div>

View File

@@ -18,7 +18,7 @@ import { Community } from '../../../../core/shared/community.model';
import { MetadataValue } from '../../../../core/shared/metadata.models';
import { createSuccessfulRemoteDataObject } from '../../../remote-data.utils';
import { RouterStub } from '../../../testing/router.stub';
import { DSOSelectorComponent } from '../../dso-selector/dso-selector.component';
import { AuthorizedCommunitySelectorComponent } from '../../dso-selector/authorized-community-selector/authorized-community-selector.component';
import { EditCommunitySelectorComponent } from './edit-community-selector.component';
describe('EditCommunitySelectorComponent', () => {
@@ -64,7 +64,7 @@ describe('EditCommunitySelectorComponent', () => {
})
.overrideComponent(EditCommunitySelectorComponent, {
remove: {
imports: [DSOSelectorComponent],
imports: [AuthorizedCommunitySelectorComponent],
},
})
.compileComponents();

View File

@@ -18,7 +18,7 @@ import {
} from '../../../../core/cache/models/sort-options.model';
import { DSpaceObject } from '../../../../core/shared/dspace-object.model';
import { DSpaceObjectType } from '../../../../core/shared/dspace-object-type.model';
import { DSOSelectorComponent } from '../../dso-selector/dso-selector.component';
import { AuthorizedCommunitySelectorComponent } from '../../dso-selector/authorized-community-selector/authorized-community-selector.component';
import {
DSOSelectorModalWrapperComponent,
SelectorActionType,
@@ -31,9 +31,9 @@ import {
@Component({
selector: 'ds-base-edit-community-selector',
templateUrl: '../dso-selector-modal-wrapper.component.html',
templateUrl: './edit-community-selector.component.html',
standalone: true,
imports: [NgIf, DSOSelectorComponent, TranslateModule],
imports: [NgIf, AuthorizedCommunitySelectorComponent, TranslateModule],
})
export class EditCommunitySelectorComponent extends DSOSelectorModalWrapperComponent implements OnInit {

View File

@@ -4,8 +4,8 @@ import {
} from '@angular/common';
import { Component } from '@angular/core';
import { TranslateModule } from '@ngx-translate/core';
import { AuthorizedCommunitySelectorComponent } from 'src/app/shared/dso-selector/dso-selector/authorized-community-selector/authorized-community-selector.component';
import { DSOSelectorComponent } from '../../../../../../../app/shared/dso-selector/dso-selector/dso-selector.component';
import { CreateCommunityParentSelectorComponent as BaseComponent } from '../../../../../../../app/shared/dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component';
@Component({
@@ -17,7 +17,7 @@ import { CreateCommunityParentSelectorComponent as BaseComponent } from '../../.
standalone: true,
imports: [
AsyncPipe,
DSOSelectorComponent,
AuthorizedCommunitySelectorComponent,
NgIf,
TranslateModule,
],