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 + 'search.head' | translate}}
+
+
+
0"
+ [paginationOptions]="config"
+ [pageInfoState]="(ePeople | async)?.payload"
+ [collectionSize]="(ePeople | async)?.payload?.totalElements"
+ [hideGear]="true"
+ [hidePagerWhenSinglePage]="true"
+ (pageChange)="onPageChange($event)">
+
+
+
+
+
+ {{labelPrefix + 'table.id' | translate}} |
+ {{labelPrefix + 'table.name' | translate}} |
+ {{labelPrefix + 'table.email' | translate}} |
+ {{labelPrefix + 'table.edit' | translate}} |
+
+
+
+
+ {{eperson.id}} |
+ {{eperson.name}} |
+ {{eperson.email}} |
+
+
+
+
+
+ |
+
+
+
+
+
+
+
+
+ {{labelPrefix + 'no-items' | translate}}
+
+
+
+
+
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);
+ }
+
}