diff --git a/resources/i18n/en.json5 b/resources/i18n/en.json5 index d3ce471e05..6b70888484 100644 --- a/resources/i18n/en.json5 +++ b/resources/i18n/en.json5 @@ -170,6 +170,66 @@ + "admin.access-control.epeople.title": "DSpace Angular :: EPeople", + + "admin.access-control.epeople.head": "EPeople", + + "admin.access-control.epeople.search.head": "Search", + + "admin.access-control.epeople.search.scope.name": "Name", + + "admin.access-control.epeople.search.scope.email": "E-mail", + + "admin.access-control.epeople.search.scope.metadata": "Metadata", + + "admin.access-control.epeople.search.button": "Search", + + "admin.access-control.epeople.button.add": "Add EPerson", + + "admin.access-control.epeople.table.id": "ID", + + "admin.access-control.epeople.table.name": "Name", + + "admin.access-control.epeople.table.email": "E-mail", + + "admin.access-control.epeople.table.edit": "Edit", + + "item.access-control.epeople.table.edit.buttons.edit": "Edit", + + "item.access-control.epeople.table.edit.buttons.remove": "Remove", + + "admin.access-control.epeople.no-items": "No EPeople to show.", + + "admin.access-control.epeople.form.create": "Create EPerson", + + "admin.access-control.epeople.form.edit": "Edit EPerson", + + "admin.access-control.epeople.form.firstName": "First name", + + "admin.access-control.epeople.form.lastName": "Last name", + + "admin.access-control.epeople.form.email": "E-mail", + + "admin.access-control.epeople.form.emailHint": "Must be valid e-mail address", + + "admin.access-control.epeople.form.canLogIn": "Can log in", + + "admin.access-control.epeople.form.requireCertificate": "Requires certificate", + + "admin.access-control.epeople.form.notification.created.success": "Successfully created EPerson \"{{name}}\"", + + "admin.access-control.epeople.form.notification.created.failure": "Failed to create EPerson \"{{name}}\"", + + "admin.access-control.epeople.form.notification.edited.success": "Successfully edited EPerson \"{{name}}\"", + + "admin.access-control.epeople.form.notification.edited.failure": "Failed to edit EPerson \"{{name}}\"", + + "admin.access-control.epeople.notification.deleted.failure": "Failed to delete EPerson: \"{{name}}\"", + + "admin.access-control.epeople.notification.deleted.success": "Successfully deleted EPerson: \"{{name}}\"", + + + "admin.search.breadcrumbs": "Administrative Search", "admin.search.collection.edit": "Edit", @@ -1500,7 +1560,7 @@ "relationships.isSingleVolumeOf": "Journal Volume", "relationships.isVolumeOf": "Journal Volumes", - + "relationships.isContributorOf": "Contributors", diff --git a/src/app/+admin/admin-access-control/admin-access-control-routing.module.ts b/src/app/+admin/admin-access-control/admin-access-control-routing.module.ts new file mode 100644 index 0000000000..8f1927ad63 --- /dev/null +++ b/src/app/+admin/admin-access-control/admin-access-control-routing.module.ts @@ -0,0 +1,14 @@ +import { NgModule } from '@angular/core'; +import { RouterModule } from '@angular/router'; +import { EPeopleRegistryComponent } from './epeople-registry/epeople-registry.component'; + +@NgModule({ + imports: [ + RouterModule.forChild([ + { path: 'epeople', component: EPeopleRegistryComponent, data: { title: 'admin.access-control.epeople.title' } }, + ]) + ] +}) +export class AdminAccessControlRoutingModule { + +} diff --git a/src/app/+admin/admin-access-control/admin-access-control.module.ts b/src/app/+admin/admin-access-control/admin-access-control.module.ts new file mode 100644 index 0000000000..5893332971 --- /dev/null +++ b/src/app/+admin/admin-access-control/admin-access-control.module.ts @@ -0,0 +1,26 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { RouterModule } from '@angular/router'; +import { TranslateModule } from '@ngx-translate/core'; +import { SharedModule } from '../../shared/shared.module'; +import { AdminAccessControlRoutingModule } from './admin-access-control-routing.module'; +import { EPeopleRegistryComponent } from './epeople-registry/epeople-registry.component'; +import { EPersonFormComponent } from './epeople-registry/eperson-form/eperson-form.component'; + +@NgModule({ + imports: [ + CommonModule, + SharedModule, + RouterModule, + TranslateModule, + AdminAccessControlRoutingModule + ], + declarations: [ + EPeopleRegistryComponent, + EPersonFormComponent + ], + entryComponents: [] +}) +export class AdminAccessControlModule { + +} diff --git a/src/app/+admin/admin-access-control/epeople-registry/epeople-registry.actions.ts b/src/app/+admin/admin-access-control/epeople-registry/epeople-registry.actions.ts new file mode 100644 index 0000000000..b2d1c1b8ac --- /dev/null +++ b/src/app/+admin/admin-access-control/epeople-registry/epeople-registry.actions.ts @@ -0,0 +1,49 @@ +import { Action } from '@ngrx/store'; +import { EPerson } from '../../../core/eperson/models/eperson.model'; +import { type } from '../../../shared/ngrx/type'; + +/** + * For each action type in an action group, make a simple + * enum object for all of this group's action types. + * + * The 'type' utility function coerces strings into string + * literal types and runs a simple check to guarantee all + * action types in the application are unique. + */ +export const EPeopleRegistryActionTypes = { + + EDIT_EPERSON: type('dspace/epeople-registry/EDIT_EPERSON'), + CANCEL_EDIT_EPERSON: type('dspace/epeople-registry/CANCEL_SCHEMA'), +}; + +/* tslint:disable:max-classes-per-file */ +/** + * Used to edit an EPerson in the EPeople registry + */ +export class EPeopleRegistryEditEPersonAction implements Action { + type = EPeopleRegistryActionTypes.EDIT_EPERSON; + + eperson: EPerson; + + constructor(registry: EPerson) { + this.eperson = registry; + } +} + +/** + * Used to cancel the editing of an EPerson in the EPeople registry + */ +export class EPeopleRegistryCancelEPersonAction implements Action { + type = EPeopleRegistryActionTypes.CANCEL_EDIT_EPERSON; +} + +/* tslint:enable:max-classes-per-file */ + +/** + * Export a type alias of all actions in this action group + * so that reducers can easily compose action types + * These are all the actions to perform on the EPeople registry state + */ +export type EPeopleRegistryAction + = EPeopleRegistryEditEPersonAction + | EPeopleRegistryCancelEPersonAction diff --git a/src/app/+admin/admin-access-control/epeople-registry/epeople-registry.component.html b/src/app/+admin/admin-access-control/epeople-registry/epeople-registry.component.html new file mode 100644 index 0000000000..46c3e8fe12 --- /dev/null +++ b/src/app/+admin/admin-access-control/epeople-registry/epeople-registry.component.html @@ -0,0 +1,90 @@ +
+
+
+ + + + + +
+ +
+ + +
+
+ +
+
+
+ + + + +
+
+
+ + + +
+ + + + + + + + + + + + + + + + + +
{{labelPrefix + 'table.id' | translate}}{{labelPrefix + 'table.name' | translate}}{{labelPrefix + 'table.email' | translate}}{{labelPrefix + 'table.edit' | translate}}
{{eperson.id}}{{eperson.name}}{{eperson.email}} +
+ + +
+
+
+ +
+ + + +
+
+
diff --git a/src/app/+admin/admin-access-control/epeople-registry/epeople-registry.component.ts b/src/app/+admin/admin-access-control/epeople-registry/epeople-registry.component.ts new file mode 100644 index 0000000000..3994c3e839 --- /dev/null +++ b/src/app/+admin/admin-access-control/epeople-registry/epeople-registry.component.ts @@ -0,0 +1,190 @@ +import { Component } from '@angular/core'; +import { FormBuilder } from '@angular/forms'; +import { TranslateService } from '@ngx-translate/core'; +import { Observable } from 'rxjs'; +import { map, take } from 'rxjs/operators'; +import { PaginatedList } from '../../../core/data/paginated-list'; +import { RemoteData } from '../../../core/data/remote-data'; +import { EPersonDataService } from '../../../core/eperson/eperson-data.service'; +import { EPerson } from '../../../core/eperson/models/eperson.model'; +import { hasValue } from '../../../shared/empty.util'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model'; + +@Component({ + selector: 'ds-epeople-registry', + templateUrl: './epeople-registry.component.html', +}) +/** + * A component used for managing all existing epeople within the repository. + * The admin can create, edit or delete epeople here. + */ +export class EPeopleRegistryComponent { + + labelPrefix = 'admin.access-control.epeople.'; + + /** + * A list of all the current EPeople within the repository or the result of the search + */ + ePeople: Observable>>; + + /** + * Pagination config used to display the list of epeople + */ + config: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), { + id: 'epeople-list-pagination', + pageSize: 5, + currentPage: 1 + }); + + /** + * Whether or not to show the EPerson form + */ + isEPersonFormShown: boolean; + + // The search form + searchForm; + + constructor(private epersonService: EPersonDataService, + private translateService: TranslateService, + private notificationsService: NotificationsService, + private formBuilder: FormBuilder) { + this.updateEPeople({ + currentPage: 1, + elementsPerPage: this.config.pageSize + }); + this.isEPersonFormShown = false; + this.searchForm = this.formBuilder.group(({ + scope: 'name', + query: '', + })); + } + + /** + * Event triggered when the user changes page + * @param event + */ + onPageChange(event) { + this.updateEPeople({ + currentPage: event, + elementsPerPage: this.config.pageSize + }); + } + + /** + * Update the list of EPeople by fetching it from the rest api or cache + */ + private updateEPeople(options) { + this.ePeople = this.epersonService.getEPeople(options); + } + + /** + * Force-update the list of EPeople by first clearing the cache related to EPeople, then performing + * a new REST call + */ + public forceUpdateEPeople() { + this.epersonService.clearEPersonRequests(); + this.isEPersonFormShown = false; + this.updateEPeople({ + currentPage: 1, + elementsPerPage: this.config.pageSize + }); + } + + /** + * Search in the EPeople by name, email or metadata + * @param data Contains scope and query param + */ + search(data: any) { + const query = data.query; + const scope = data.scope; + switch (scope) { + case 'name': + this.ePeople = this.epersonService.getEpeopleByName(query, { + currentPage: 1, + elementsPerPage: this.config.pageSize + }); + break; + case 'email': + this.ePeople = this.epersonService.getEpeopleByEmail(query, { + currentPage: 1, + elementsPerPage: this.config.pageSize + }); + break; + case 'metadata': + this.ePeople = this.epersonService.getEpeopleByMetadata(query, { + currentPage: 1, + elementsPerPage: this.config.pageSize + }); + break; + default: + this.ePeople = this.epersonService.getEpeopleByEmail(query, { + currentPage: 1, + elementsPerPage: this.config.pageSize + }); + break; + } + } + + /** + * Checks whether the given EPerson is active (being edited) + * @param eperson + */ + isActive(eperson: EPerson): Observable { + return this.getActiveEPerson().pipe( + map((activeEPerson) => eperson === activeEPerson) + ); + } + + /** + * Gets the active eperson (being edited) + */ + getActiveEPerson(): Observable { + return this.epersonService.getActiveEPerson(); + } + + /** + * Start editing the selected metadata schema + * @param schema + */ + editEPerson(ePerson: EPerson) { + this.getActiveEPerson().pipe(take(1)).subscribe((activeEPerson) => { + if (ePerson === activeEPerson) { + this.epersonService.cancelEditEPerson(); + this.isEPersonFormShown = false; + } else { + this.epersonService.editEPerson(ePerson); + this.isEPersonFormShown = true; + } + }); + this.scrollToTop() + } + + /** + * Delete EPerson + */ + deleteEPerson(ePerson: EPerson) { + if (hasValue(ePerson.id)) { + this.epersonService.deleteEPerson(ePerson).pipe(take(1)).subscribe((success: boolean) => { + if (success) { + this.notificationsService.success(this.translateService.get(this.labelPrefix + 'notification.deleted.success', { name: ePerson.name })); + this.forceUpdateEPeople(); + } else { + this.notificationsService.success(this.translateService.get(this.labelPrefix + 'notification.deleted.failure', { name: ePerson.name })); + } + this.epersonService.cancelEditEPerson(); + this.isEPersonFormShown = false; + }) + } + } + + scrollToTop() { + (function smoothscroll() { + const currentScroll = document.documentElement.scrollTop || document.body.scrollTop; + if (currentScroll > 0) { + window.requestAnimationFrame(smoothscroll); + window.scrollTo(0, currentScroll - (currentScroll / 8)); + } + })(); + } +} diff --git a/src/app/+admin/admin-access-control/epeople-registry/epeople-registry.reducers.ts b/src/app/+admin/admin-access-control/epeople-registry/epeople-registry.reducers.ts new file mode 100644 index 0000000000..55fea8f862 --- /dev/null +++ b/src/app/+admin/admin-access-control/epeople-registry/epeople-registry.reducers.ts @@ -0,0 +1,49 @@ +import { EPerson } from '../../../core/eperson/models/eperson.model'; +import { + EPeopleRegistryAction, + EPeopleRegistryActionTypes, + EPeopleRegistryEditEPersonAction +} from './epeople-registry.actions'; + +/** + * The metadata registry state. + * @interface EPeopleRegistryState + */ +export interface EPeopleRegistryState { + editEPerson: EPerson; + selectedEPeople: EPerson[]; +} + +/** + * The initial state. + */ +const initialState: EPeopleRegistryState = { + editEPerson: null, + selectedEPeople: [], +}; + +/** + * Reducer that handles EPeopleRegistryActions to modify EPeople + * @param state The current EPeopleRegistryState + * @param action The EPeopleRegistryAction to perform on the state + */ +export function ePeopleRegistryReducer(state = initialState, action: EPeopleRegistryAction): EPeopleRegistryState { + + switch (action.type) { + + case EPeopleRegistryActionTypes.EDIT_EPERSON: { + return Object.assign({}, state, { + editEPerson: (action as EPeopleRegistryEditEPersonAction).eperson + }); + } + + case EPeopleRegistryActionTypes.CANCEL_EDIT_EPERSON: { + return Object.assign({}, state, { + editEPerson: null + }); + } + + default: + return state; + } +} diff --git a/src/app/+admin/admin-access-control/epeople-registry/eperson-form/eperson-form.component.html b/src/app/+admin/admin-access-control/epeople-registry/eperson-form/eperson-form.component.html new file mode 100644 index 0000000000..6d44dd7fa1 --- /dev/null +++ b/src/app/+admin/admin-access-control/epeople-registry/eperson-form/eperson-form.component.html @@ -0,0 +1,17 @@ +
+ + +

{{messagePrefix + '.create' | translate}}

+
+ + +

{{messagePrefix + '.edit' | translate}}

+
+ + + diff --git a/src/app/+admin/admin-access-control/epeople-registry/eperson-form/eperson-form.component.ts b/src/app/+admin/admin-access-control/epeople-registry/eperson-form/eperson-form.component.ts new file mode 100644 index 0000000000..ce08c2c0f8 --- /dev/null +++ b/src/app/+admin/admin-access-control/epeople-registry/eperson-form/eperson-form.component.ts @@ -0,0 +1,291 @@ +import { Component, EventEmitter, OnDestroy, OnInit, Output } from '@angular/core'; +import { FormGroup } from '@angular/forms'; +import { + DynamicCheckboxModel, + DynamicFormControlModel, + DynamicFormLayout, + DynamicInputModel +} from '@ng-dynamic-forms/core'; +import { TranslateService } from '@ngx-translate/core'; +import { combineLatest } from 'rxjs/internal/observable/combineLatest'; +import { take } from 'rxjs/operators'; +import { EPersonDataService } from '../../../../core/eperson/eperson-data.service'; +import { EPerson } from '../../../../core/eperson/models/eperson.model'; +import { getRemoteDataPayload, getSucceededRemoteData } from '../../../../core/shared/operators'; +import { hasValue } from '../../../../shared/empty.util'; +import { FormBuilderService } from '../../../../shared/form/builder/form-builder.service'; +import { NotificationsService } from '../../../../shared/notifications/notifications.service'; + +@Component({ + selector: 'ds-eperson-form', + templateUrl: './eperson-form.component.html' +}) +/** + * A form used for creating and editing EPeople + */ +export class EPersonFormComponent implements OnInit, OnDestroy { + + labelPrefix = 'admin.access-control.epeople.form.'; + + /** + * A unique id used for ds-form + */ + formId = 'eperson-form'; + + /** + * The labelPrefix for all messages related to this form + */ + messagePrefix = 'admin.access-control.epeople.form'; + + /** + * Dynamic input models for the inputs of form + */ + firstName: DynamicInputModel; + lastName: DynamicInputModel; + email: DynamicInputModel; + // booleans + canLogIn: DynamicCheckboxModel; + requireCertificate: DynamicCheckboxModel; + + /** + * A list of all dynamic input models + */ + formModel: DynamicFormControlModel[]; + + /** + * Layout used for structuring the form inputs + */ + formLayout: DynamicFormLayout = { + firstName: { + grid: { + host: 'row' + } + }, + lastName: { + grid: { + host: 'row' + } + }, + email: { + grid: { + host: 'row' + } + }, + canLogIn: { + grid: { + host: 'col col-sm-6 d-inline-block' + } + }, + requireCertificate: { + grid: { + host: 'col col-sm-6 d-inline-block' + } + }, + }; + + /** + * A FormGroup that combines all inputs + */ + formGroup: FormGroup; + + /** + * An EventEmitter that's fired whenever the form is being submitted + */ + @Output() submitForm: EventEmitter = new EventEmitter(); + + /** + * An EventEmitter that's fired whenever the form is cancelled + */ + @Output() cancelForm: EventEmitter = new EventEmitter(); + + constructor(private epersonService: EPersonDataService, + private formBuilderService: FormBuilderService, + private translateService: TranslateService, + private notificationsService: NotificationsService,) { + } + + ngOnInit() { + combineLatest( + this.translateService.get(`${this.messagePrefix}.firstName`), + this.translateService.get(`${this.messagePrefix}.lastName`), + this.translateService.get(`${this.messagePrefix}.email`), + this.translateService.get(`${this.messagePrefix}.canLogIn`), + this.translateService.get(`${this.messagePrefix}.requireCertificate`), + this.translateService.get(`${this.messagePrefix}.emailHint`), + ).subscribe(([firstName, lastName, email, canLogIn, requireCertificate, emailHint]) => { + this.firstName = new DynamicInputModel({ + id: 'firstName', + label: firstName, + name: 'firstName', + validators: { + required: null, + }, + required: true, + }); + this.lastName = new DynamicInputModel({ + id: 'lastName', + label: lastName, + name: 'lastName', + validators: { + required: null, + }, + required: true, + }); + this.email = new DynamicInputModel({ + id: 'email', + label: email, + name: 'email', + validators: { + required: null, + pattern: '^[a-z0-9._%+-]+@[a-z0-9.-]+\\.[a-z]{2,4}$' + }, + required: true, + hint: emailHint + }); + this.canLogIn = new DynamicCheckboxModel( + { + id: 'canLogIn', + label: canLogIn, + name: 'canLogIn', + }); + this.requireCertificate = new DynamicCheckboxModel( + { + id: 'requireCertificate', + label: requireCertificate, + name: 'requireCertificate', + }); + this.formModel = [ + this.firstName, + this.lastName, + this.email, + this.canLogIn, + this.requireCertificate, + ]; + this.formGroup = this.formBuilderService.createFormGroup(this.formModel); + this.epersonService.getActiveEPerson().subscribe((eperson: EPerson) => { + this.formGroup.patchValue({ + firstName: eperson != null ? eperson.firstMetadataValue('eperson.firstname') : '', + lastName: eperson != null ? eperson.firstMetadataValue('eperson.lastname') : '', + email: eperson != null ? eperson.email : '', + canLogIn: eperson != null ? eperson.canLogIn : true, + requireCertificate: eperson != null ? eperson.requireCertificate : false, + selfRegistered: false, + }); + }); + }); + } + + /** + * Stop editing the currently selected eperson + */ + onCancel() { + this.epersonService.cancelEditEPerson(); + this.cancelForm.emit(); + } + + /** + * Submit the form + * When the eperson has an id attached -> Edit the eperson + * When the eperson has no id attached -> Create new eperson + * Emit the updated/created eperson using the EventEmitter submitForm + */ + onSubmit() { + this.epersonService.getActiveEPerson().pipe(take(1)).subscribe( + (ePerson: EPerson) => { + const values = { + metadata: { + 'eperson.firstname': [ + { + value: this.firstName.value + } + ], + 'eperson.lastname': [ + { + value: this.lastName.value + }, + ], + }, + email: this.email.value, + canLogIn: this.canLogIn.value, + requireCertificate: this.requireCertificate.value, + selfRegistered: false, + }; + if (ePerson == null) { + this.createNewEPerson(values); + } else { + this.editEPerson(ePerson, values); + } + this.clearFields(); + } + ); + } + + /** + * Creates new EPerson based on given values from form + * @param values + */ + createNewEPerson(values) { + this.epersonService.createOrUpdateEPerson(Object.assign(new EPerson(), values)) + .pipe( + getSucceededRemoteData(), + getRemoteDataPayload()) + .subscribe((newEPerson: EPerson) => { + this.notificationsService.success(this.translateService.get(this.labelPrefix + 'notification.created.success', { name: newEPerson.name })); + this.submitForm.emit(newEPerson); + }); + } + + /** + * Edits existing EPerson based on given values from form and old EPerson + * @param ePerson + * @param values + */ + editEPerson(ePerson: EPerson, values) { + this.epersonService.createOrUpdateEPerson(Object.assign(new EPerson(), { + id: ePerson.id, + metadata: { + 'eperson.firstname': [ + { + value: (this.firstName.value ? this.firstName.value : ePerson.firstMetadataValue('eperson.firstname')) + } + ], + 'eperson.lastname': [ + { + value: (this.lastName.value ? this.lastName.value : ePerson.firstMetadataValue('eperson.lastname')) + }, + ], + }, + email: (hasValue(values.email) ? values.email : ePerson.email), + canLogIn: (hasValue(values.canLogIn) ? values.canLogIn : ePerson.canLogIn), + requireCertificate: (hasValue(values.requireCertificate) ? values.requireCertificate : ePerson.requireCertificate), + selfRegistered: false, + _links: ePerson._links, + })) + .pipe( + getSucceededRemoteData(), + getRemoteDataPayload()) + .subscribe((updatedEPerson: EPerson) => { + this.notificationsService.success(this.translateService.get(this.labelPrefix + 'notification.edited.success', { name: updatedEPerson.name })); + this.submitForm.emit(updatedEPerson); + }); + } + + /** + * Reset all input-fields to be empty + */ + clearFields() { + this.formGroup.patchValue({ + firstName: '', + lastName: '', + email: '', + canLogin: '', + }); + } + + /** + * Cancel the current edit when component is destroyed + */ + ngOnDestroy(): void { + this.onCancel(); + } +} diff --git a/src/app/+admin/admin-routing.module.ts b/src/app/+admin/admin-routing.module.ts index 373551d3c0..2e583ec175 100644 --- a/src/app/+admin/admin-routing.module.ts +++ b/src/app/+admin/admin-routing.module.ts @@ -1,11 +1,12 @@ -import { RouterModule } from '@angular/router'; import { NgModule } from '@angular/core'; -import { URLCombiner } from '../core/url-combiner/url-combiner'; +import { RouterModule } from '@angular/router'; import { getAdminModulePath } from '../app-routing.module'; +import { URLCombiner } from '../core/url-combiner/url-combiner'; import { AdminSearchPageComponent } from './admin-search-page/admin-search-page.component'; import { I18nBreadcrumbResolver } from '../core/breadcrumbs/i18n-breadcrumb.resolver'; const REGISTRIES_MODULE_PATH = 'registries'; +const ACCESS_CONTROL_MODULE_PATH = 'access-control'; export function getRegistriesModulePath() { return new URLCombiner(getAdminModulePath(), REGISTRIES_MODULE_PATH).toString(); @@ -18,6 +19,10 @@ export function getRegistriesModulePath() { path: REGISTRIES_MODULE_PATH, loadChildren: './admin-registries/admin-registries.module#AdminRegistriesModule' }, + { + path: ACCESS_CONTROL_MODULE_PATH, + loadChildren: './admin-access-control/admin-access-control.module#AdminAccessControlModule' + }, { path: 'search', resolve: { breadcrumb: I18nBreadcrumbResolver }, diff --git a/src/app/+admin/admin-sidebar/admin-sidebar.component.ts b/src/app/+admin/admin-sidebar/admin-sidebar.component.ts index 9f4fa5f3f6..e3f55b8e18 100644 --- a/src/app/+admin/admin-sidebar/admin-sidebar.component.ts +++ b/src/app/+admin/admin-sidebar/admin-sidebar.component.ts @@ -1,23 +1,23 @@ import { Component, Injector, OnInit } from '@angular/core'; -import { Observable } from 'rxjs/internal/Observable'; -import { slideHorizontal, slideSidebar } from '../../shared/animations/slide'; -import { CSSVariableService } from '../../shared/sass-helper/sass-helper.service'; -import { MenuService } from '../../shared/menu/menu.service'; -import { MenuID, MenuItemType } from '../../shared/menu/initial-menus-state'; -import { MenuComponent } from '../../shared/menu/menu.component'; -import { TextMenuItemModel } from '../../shared/menu/menu-item/models/text.model'; -import { LinkMenuItemModel } from '../../shared/menu/menu-item/models/link.model'; -import { AuthService } from '../../core/auth/auth.service'; -import { first, map } from 'rxjs/operators'; -import { combineLatest as combineLatestObservable } from 'rxjs'; import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; -import { OnClickMenuItemModel } from '../../shared/menu/menu-item/models/onclick.model'; -import { CreateCommunityParentSelectorComponent } from '../../shared/dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component'; +import { combineLatest as combineLatestObservable } from 'rxjs'; +import { Observable } from 'rxjs/internal/Observable'; +import { first, map } from 'rxjs/operators'; +import { AuthService } from '../../core/auth/auth.service'; +import { slideHorizontal, slideSidebar } from '../../shared/animations/slide'; import { CreateCollectionParentSelectorComponent } from '../../shared/dso-selector/modal-wrappers/create-collection-parent-selector/create-collection-parent-selector.component'; -import { EditItemSelectorComponent } from '../../shared/dso-selector/modal-wrappers/edit-item-selector/edit-item-selector.component'; -import { EditCommunitySelectorComponent } from '../../shared/dso-selector/modal-wrappers/edit-community-selector/edit-community-selector.component'; +import { CreateCommunityParentSelectorComponent } from '../../shared/dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component'; +import { CreateItemParentSelectorComponent } from '../../shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component'; import { EditCollectionSelectorComponent } from '../../shared/dso-selector/modal-wrappers/edit-collection-selector/edit-collection-selector.component'; -import {CreateItemParentSelectorComponent} from '../../shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component'; +import { EditCommunitySelectorComponent } from '../../shared/dso-selector/modal-wrappers/edit-community-selector/edit-community-selector.component'; +import { EditItemSelectorComponent } from '../../shared/dso-selector/modal-wrappers/edit-item-selector/edit-item-selector.component'; +import { MenuID, MenuItemType } from '../../shared/menu/initial-menus-state'; +import { LinkMenuItemModel } from '../../shared/menu/menu-item/models/link.model'; +import { OnClickMenuItemModel } from '../../shared/menu/menu-item/models/onclick.model'; +import { TextMenuItemModel } from '../../shared/menu/menu-item/models/text.model'; +import { MenuComponent } from '../../shared/menu/menu.component'; +import { MenuService } from '../../shared/menu/menu.service'; +import { CSSVariableService } from '../../shared/sass-helper/sass-helper.service'; /** * Component representing the admin sidebar @@ -325,7 +325,7 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit { model: { type: MenuItemType.LINK, text: 'menu.section.access_control_people', - link: '' + link: '/admin/access-control/epeople' } as LinkMenuItemModel, }, { diff --git a/src/app/+admin/admin.module.ts b/src/app/+admin/admin.module.ts index 90fe03cba0..d1db17535f 100644 --- a/src/app/+admin/admin.module.ts +++ b/src/app/+admin/admin.module.ts @@ -1,7 +1,7 @@ import { NgModule } from '@angular/core'; -import { AdminRegistriesModule } from './admin-registries/admin-registries.module'; -import { AdminRoutingModule } from './admin-routing.module'; import { SharedModule } from '../shared/shared.module'; +import { AdminAccessControlModule } from './admin-access-control/admin-access-control.module'; +import { AdminRegistriesModule } from './admin-registries/admin-registries.module'; import { AdminSearchPageComponent } from './admin-search-page/admin-search-page.component'; import { SearchPageModule } from '../+search-page/search-page.module'; import { ItemAdminSearchResultListElementComponent } from './admin-search-page/admin-search-results/admin-search-result-list-element/item-search-result/item-admin-search-result-list-element.component'; @@ -15,7 +15,7 @@ import { ItemAdminSearchResultActionsComponent } from './admin-search-page/admin @NgModule({ imports: [ AdminRegistriesModule, - AdminRoutingModule, + AdminAccessControlModule, SharedModule, SearchPageModule ], diff --git a/src/app/app.reducer.ts b/src/app/app.reducer.ts index 837fb9befd..a40005814a 100644 --- a/src/app/app.reducer.ts +++ b/src/app/app.reducer.ts @@ -1,30 +1,34 @@ -import { ActionReducerMap, createSelector, MemoizedSelector } from '@ngrx/store'; import * as fromRouter from '@ngrx/router-store'; - -import { hostWindowReducer, HostWindowState } from './shared/search/host-window.reducer'; -import { CommunityListReducer, CommunityListState } from './community-list-page/community-list.reducer'; -import { formReducer, FormState } from './shared/form/form.reducer'; -import { sidebarReducer, SidebarState } from './shared/sidebar/sidebar.reducer'; -import { sidebarFilterReducer, SidebarFiltersState } from './shared/sidebar/filter/sidebar-filter.reducer'; -import { filterReducer, SearchFiltersState } from './shared/search/search-filters/search-filter/search-filter.reducer'; -import { notificationsReducer, NotificationsState } from './shared/notifications/notifications.reducers'; -import { truncatableReducer, TruncatablesState } from './shared/truncatable/truncatable.reducer'; +import { ActionReducerMap, createSelector, MemoizedSelector } from '@ngrx/store'; +import { + ePeopleRegistryReducer, + EPeopleRegistryState +} from './+admin/admin-access-control/epeople-registry/epeople-registry.reducers'; import { metadataRegistryReducer, MetadataRegistryState } from './+admin/admin-registries/metadata-registry/metadata-registry.reducers'; +import { CommunityListReducer, CommunityListState } from './community-list-page/community-list.reducer'; import { hasValue } from './shared/empty.util'; -import { cssVariablesReducer, CSSVariablesState } from './shared/sass-helper/sass-helper.reducer'; +import { + NameVariantListsState, + nameVariantReducer +} from './shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/name-variant.reducer'; +import { formReducer, FormState } from './shared/form/form.reducer'; import { menusReducer, MenusState } from './shared/menu/menu.reducer'; +import { notificationsReducer, NotificationsState } from './shared/notifications/notifications.reducers'; import { selectableListReducer, SelectableListsState } from './shared/object-list/selectable-list/selectable-list.reducer'; import { ObjectSelectionListState, objectSelectionReducer } from './shared/object-select/object-select.reducer'; -import { - NameVariantListsState, - nameVariantReducer -} from './shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/name-variant.reducer'; +import { cssVariablesReducer, CSSVariablesState } from './shared/sass-helper/sass-helper.reducer'; + +import { hostWindowReducer, HostWindowState } from './shared/search/host-window.reducer'; +import { filterReducer, SearchFiltersState } from './shared/search/search-filters/search-filter/search-filter.reducer'; +import { sidebarFilterReducer, SidebarFiltersState } from './shared/sidebar/filter/sidebar-filter.reducer'; +import { sidebarReducer, SidebarState } from './shared/sidebar/sidebar.reducer'; +import { truncatableReducer, TruncatablesState } from './shared/truncatable/truncatable.reducer'; export interface AppState { router: fromRouter.RouterReducerState; @@ -42,6 +46,7 @@ export interface AppState { selectableLists: SelectableListsState; relationshipLists: NameVariantListsState; communityList: CommunityListState; + epeopleRegistry: EPeopleRegistryState; } export const appReducers: ActionReducerMap = { @@ -60,6 +65,7 @@ export const appReducers: ActionReducerMap = { selectableLists: selectableListReducer, relationshipLists: nameVariantReducer, communityList: CommunityListReducer, + epeopleRegistry: ePeopleRegistryReducer, }; export const routerStateSelector = (state: AppState) => state.router; diff --git a/src/app/core/cache/object-cache.service.ts b/src/app/core/cache/object-cache.service.ts index 53894df5f1..036c9f0ed8 100644 --- a/src/app/core/cache/object-cache.service.ts +++ b/src/app/core/cache/object-cache.service.ts @@ -10,6 +10,7 @@ import { coreSelector } from '../core.selectors'; import { RestRequestMethod } from '../data/rest-request-method'; import { selfLinkFromUuidSelector } from '../index/index.selectors'; import { GenericConstructor } from '../shared/generic-constructor'; +import { getClassForType } from './builders/build-decorators'; import { LinkService } from './builders/link.service'; import { AddPatchObjectCacheAction, @@ -20,7 +21,6 @@ import { import { CacheableObject, ObjectCacheEntry, ObjectCacheState } from './object-cache.reducer'; import { AddToSSBAction } from './server-sync-buffer.actions'; -import { getClassForType } from './builders/build-decorators'; /** * The base selector function to select the object cache in the store @@ -48,7 +48,7 @@ export class ObjectCacheService { constructor( private store: Store, private linkService: LinkService - ) { + ) { } /** @@ -276,6 +276,8 @@ export class ObjectCacheService { * list of operations to perform */ public addPatch(selfLink: string, patch: Operation[]) { + console.log('selfLink addPatch', selfLink) + console.log('patch addPatch', patch) this.store.dispatch(new AddPatchObjectCacheAction(selfLink, patch)); this.store.dispatch(new AddToSSBAction(selfLink, RestRequestMethod.PATCH)); } diff --git a/src/app/core/eperson/eperson-data.service.ts b/src/app/core/eperson/eperson-data.service.ts index ef2e76c7c6..5262f800d1 100644 --- a/src/app/core/eperson/eperson-data.service.ts +++ b/src/app/core/eperson/eperson-data.service.ts @@ -1,31 +1,52 @@ import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core'; -import { Store } from '@ngrx/store'; +import { createSelector, select, Store } from '@ngrx/store'; +import { Operation } from 'fast-json-patch/lib/core'; +import { Observable } from 'rxjs'; +import { filter, mergeMap, take } from 'rxjs/operators'; +import { + EPeopleRegistryCancelEPersonAction, + EPeopleRegistryEditEPersonAction +} from '../../+admin/admin-access-control/epeople-registry/epeople-registry.actions'; +import { EPeopleRegistryState } from '../../+admin/admin-access-control/epeople-registry/epeople-registry.reducers'; +import { AppState } from '../../app.reducer'; +import { hasValue } from '../../shared/empty.util'; import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { dataService } from '../cache/builders/build-decorators'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { SearchParam } from '../cache/models/search-param.model'; import { ObjectCacheService } from '../cache/object-cache.service'; -import { CoreState } from '../core.reducers'; import { DataService } from '../data/data.service'; import { DSOChangeAnalyzer } from '../data/dso-change-analyzer.service'; +import { PaginatedList } from '../data/paginated-list'; +import { RemoteData } from '../data/remote-data'; +import { FindListOptions, FindListRequest, PatchRequest, } from '../data/request.models'; import { RequestService } from '../data/request.service'; import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { getRemoteDataPayload, getSucceededRemoteData } from '../shared/operators'; import { EPerson } from './models/eperson.model'; import { EPERSON } from './models/eperson.resource-type'; +const ePeopleRegistryStateSelector = (state: AppState) => state.epeopleRegistry; +const editEPersonSelector = createSelector(ePeopleRegistryStateSelector, (ePeopleRegistryState: EPeopleRegistryState) => ePeopleRegistryState.editEPerson); + /** - * A service to retrieve {@link EPerson}s from the REST API + * A service to retrieve {@link EPerson}s from the REST API & EPerson related CRUD actions */ @Injectable() @dataService(EPERSON) export class EPersonDataService extends DataService { - protected linkPath: 'epersons'; + protected linkPath = 'epersons'; + protected searchByNamePath = 'byName'; + protected searchByEmailPath = 'byEmail'; + protected searchByMetadataPath = 'byMetadata'; constructor( protected requestService: RequestService, protected rdbService: RemoteDataBuildService, - protected store: Store, + protected store: Store, protected objectCache: ObjectCacheService, protected halService: HALEndpointService, protected notificationsService: NotificationsService, @@ -35,4 +56,177 @@ export class EPersonDataService extends DataService { super(); } + /** + * Retrieves all EPeople + * @param pagination The pagination info used to retrieve the EPeople + */ + public getEPeople(options: FindListOptions = {}): Observable>> { + const hrefObs = this.getFindAllHref(options, this.linkPath); + hrefObs.pipe( + filter((href: string) => hasValue(href)), + take(1)) + .subscribe((href: string) => { + const request = new FindListRequest(this.requestService.generateRequestId(), href, options); + this.requestService.configure(request); + }); + + return this.rdbService.buildList(hrefObs) as Observable>>; + } + + /** + * Returns a search result list of EPeople, by name query (/eperson/epersons/search/{@link searchByNamePath}?q=<>) + * @param query name query + * @param options + * @param linksToFollow + */ + public getEpeopleByName(query: string, options?: FindListOptions, ...linksToFollow: Array>): Observable>> { + const searchParams = [new SearchParam('q', query)]; + return this.getEPeopleBy(searchParams, this.searchByNamePath, options, ...linksToFollow); + } + + /** + * Returns a search result list of EPeople, by email query (/eperson/epersons/search/{@link searchByEmailPath}?email=<>) + * @param query email query + * @param options + * @param linksToFollow + */ + public getEpeopleByEmail(query: string, options?: FindListOptions, ...linksToFollow: Array>): Observable>> { + const searchParams = [new SearchParam('email', query)]; + return this.getEPeopleBy(searchParams, this.searchByEmailPath, options, ...linksToFollow); + } + + /** + * Returns a search result list of EPeople, by metadata query (/eperson/epersons/search/{@link searchByMetadataPath}?query=<>) + * @param query metadata query + * @param options + * @param linksToFollow + */ + public getEpeopleByMetadata(query: string, options?: FindListOptions, ...linksToFollow: Array>): Observable>> { + const searchParams = [new SearchParam('query', query)]; + return this.getEPeopleBy(searchParams, this.searchByMetadataPath, options, ...linksToFollow); + } + + /** + * Returns a search result list of EPeople in a given searchMethod, with given searchParams + * @param searchParams query parameters in the search + * @param searchMethod searchBy path + * @param options + * @param linksToFollow + */ + private getEPeopleBy(searchParams: SearchParam[], searchMethod: string, options?: FindListOptions, ...linksToFollow: Array>): Observable>> { + let findListOptions = new FindListOptions(); + if (options) { + findListOptions = Object.assign(new FindListOptions(), options); + } + if (findListOptions.searchParams) { + findListOptions.searchParams = [...findListOptions.searchParams, ...searchParams]; + } else { + findListOptions.searchParams = searchParams; + } + return this.searchBy(searchMethod, findListOptions, ...linksToFollow); + } + + /** + * Create or Update an EPerson + * If the EPerson contains an id, it is assumed the eperson already exists and is updated instead + * @param ePerson The EPerson to create or update + */ + public createOrUpdateEPerson(ePerson: EPerson): Observable> { + const isUpdate = hasValue(ePerson.id); + if (isUpdate) { + return this.updateEPerson(ePerson); + } else { + return this.create(ePerson, null); + } + } + + /** + * Add a new patch to the object cache + * The patch is derived from the differences between the given object and its version in the object cache + * @param {DSpaceObject} ePerson The given object + */ + updateEPerson(ePerson: EPerson): Observable> { + const oldVersion$ = this.findByHref(ePerson._links.self.href); + return oldVersion$.pipe( + getSucceededRemoteData(), + getRemoteDataPayload(), + mergeMap((oldEPerson: EPerson) => { + const operations = this.generateOperations(oldEPerson, ePerson); + const patchRequest = new PatchRequest(this.requestService.generateRequestId(), ePerson._links.self.href, operations); + this.requestService.configure(patchRequest); + return this.findByHref(ePerson._links.self.href); + }), + ); + } + + /** + * Metadata operations are generated by the difference between old and new EPerson + * Custom replace operations for the other EPerson values + * @param oldEPerson + * @param newEPerson + */ + generateOperations(oldEPerson: EPerson, newEPerson: EPerson): Operation[] { + let operations = this.comparator.diff(oldEPerson, newEPerson).filter((operation: Operation) => operation.op === 'replace'); + if (hasValue(oldEPerson.email) && oldEPerson.email !== newEPerson.email) { + operations = [...operations, { + op: 'replace', path: '/email', value: newEPerson.email + }] + } + if (hasValue(oldEPerson.requireCertificate) && oldEPerson.requireCertificate !== newEPerson.requireCertificate) { + operations = [...operations, { + op: 'replace', path: '/certificate', value: newEPerson.requireCertificate + }] + } + if (hasValue(oldEPerson.canLogIn) && oldEPerson.canLogIn !== newEPerson.canLogIn) { + operations = [...operations, { + op: 'replace', path: '/canLogIn', value: newEPerson.canLogIn + }] + } + if (hasValue(oldEPerson.netid) && oldEPerson.netid !== newEPerson.netid) { + operations = [...operations, { + op: 'replace', path: '/netid', value: newEPerson.netid + }] + } + return operations; + } + + /** + * Method that clears a cached EPerson request and returns its REST url + */ + public clearEPersonRequests(): void { + this.getBrowseEndpoint().pipe(take(1)).subscribe((link: string) => { + this.requestService.removeByHrefSubstring(link); + }); + } + + /** + * Method to retrieve the eperson that is currently being edited + */ + public getActiveEPerson(): Observable { + return this.store.pipe(select(editEPersonSelector)) + } + + /** + * Method to cancel editing an EPerson, dispatches a cancel EPerson action + */ + public cancelEditEPerson() { + this.store.dispatch(new EPeopleRegistryCancelEPersonAction()); + } + + /** + * Method to set the EPerson being edited, dispatches an edit EPerson action + * @param ePerson The EPerson to edit + */ + public editEPerson(ePerson: EPerson) { + this.store.dispatch(new EPeopleRegistryEditEPersonAction(ePerson)); + } + + /** + * Method to delete an EPerson + * @param id The EPerson to delete + */ + public deleteEPerson(ePerson: EPerson): Observable { + return this.delete(ePerson); + } + }