Merge pull request #618 from atmire/Manage-account-profile

Manage account profile
This commit is contained in:
Tim Donohue
2020-03-18 09:56:06 -05:00
committed by GitHub
23 changed files with 1065 additions and 29 deletions

View File

@@ -1377,6 +1377,8 @@
"nav.mydspace": "MyDSpace", "nav.mydspace": "MyDSpace",
"nav.profile": "Profile",
"nav.search": "Search", "nav.search": "Search",
"nav.statistics.header": "Statistics", "nav.statistics.header": "Statistics",
@@ -1435,6 +1437,64 @@
"profile.breadcrumbs": "Update Profile",
"profile.card.identify": "Identify",
"profile.card.security": "Security",
"profile.form.submit": "Update Profile",
"profile.groups.head": "Authorization groups you belong to",
"profile.head": "Update Profile",
"profile.metadata.form.error.firstname.required": "First Name is required",
"profile.metadata.form.error.lastname.required": "Last Name is required",
"profile.metadata.form.label.email": "Email Address",
"profile.metadata.form.label.firstname": "First Name",
"profile.metadata.form.label.language": "Language",
"profile.metadata.form.label.lastname": "Last Name",
"profile.metadata.form.label.phone": "Contact Telephone",
"profile.metadata.form.notifications.success.content": "Your changes to the profile were saved.",
"profile.metadata.form.notifications.success.title": "Profile saved",
"profile.notifications.warning.no-changes.content": "No changes were made to the Profile.",
"profile.notifications.warning.no-changes.title": "No changes",
"profile.security.form.error.matching-passwords": "The passwords do not match.",
"profile.security.form.error.password-length": "The password should be at least 6 characters long.",
"profile.security.form.info": "Optionally, you can enter a new password in the box below, and confirm it by typing it again into the second box. It should be at least six characters long.",
"profile.security.form.label.password": "Password",
"profile.security.form.label.passwordrepeat": "Retype to confirm",
"profile.security.form.notifications.success.content": "Your changes to the password were saved.",
"profile.security.form.notifications.success.title": "Password saved",
"profile.security.form.notifications.error.title": "Error changing passwords",
"profile.security.form.notifications.error.not-long-enough": "The password has to be at least 6 characters long.",
"profile.security.form.notifications.error.not-same": "The provided passwords are not the same.",
"profile.title": "Update Profile",
"project.listelement.badge": "Research Project", "project.listelement.badge": "Research Project",
"project.page.contributor": "Contributors", "project.page.contributor": "Contributors",

View File

@@ -35,6 +35,12 @@ export function getAdminModulePath() {
return `/${ADMIN_MODULE_PATH}`; return `/${ADMIN_MODULE_PATH}`;
} }
const PROFILE_MODULE_PATH = 'profile';
export function getProfileModulePath() {
return `/${PROFILE_MODULE_PATH}`;
}
export function getDSOPath(dso: DSpaceObject): string { export function getDSOPath(dso: DSpaceObject): string {
switch ((dso as any).type) { switch ((dso as any).type) {
case Community.type.value: case Community.type.value:
@@ -66,6 +72,7 @@ export function getDSOPath(dso: DSpaceObject): string {
{ path: 'submit', loadChildren: './+submit-page/submit-page.module#SubmitPageModule' }, { path: 'submit', loadChildren: './+submit-page/submit-page.module#SubmitPageModule' },
{ path: 'workspaceitems', loadChildren: './+workspaceitems-edit-page/workspaceitems-edit-page.module#WorkspaceitemsEditPageModule' }, { path: 'workspaceitems', loadChildren: './+workspaceitems-edit-page/workspaceitems-edit-page.module#WorkspaceitemsEditPageModule' },
{ path: 'workflowitems', loadChildren: './+workflowitems-edit-page/workflowitems-edit-page.module#WorkflowItemsEditPageModule' }, { path: 'workflowitems', loadChildren: './+workflowitems-edit-page/workflowitems-edit-page.module#WorkflowItemsEditPageModule' },
{ path: PROFILE_MODULE_PATH, loadChildren: './profile-page/profile-page.module#ProfilePageModule', canActivate: [AuthenticatedGuard] },
{ path: '**', pathMatch: 'full', component: PageNotFoundComponent }, { path: '**', pathMatch: 'full', component: PageNotFoundComponent },
], ],
{ {

View File

@@ -23,7 +23,7 @@ import { NativeWindowRef, NativeWindowService } from '../services/window.service
import { Base64EncodeUrl } from '../../shared/utils/encode-decode.util'; import { Base64EncodeUrl } from '../../shared/utils/encode-decode.util';
import { RouteService } from '../services/route.service'; import { RouteService } from '../services/route.service';
import { EPersonDataService } from '../eperson/eperson-data.service'; import { EPersonDataService } from '../eperson/eperson-data.service';
import { getFirstSucceededRemoteDataPayload } from '../shared/operators'; import { getAllSucceededRemoteDataPayload, getFirstSucceededRemoteDataPayload } from '../shared/operators';
export const LOGIN_ROUTE = '/login'; export const LOGIN_ROUTE = '/login';
export const LOGOUT_ROUTE = '/logout'; export const LOGOUT_ROUTE = '/logout';
@@ -149,7 +149,7 @@ export class AuthService {
*/ */
public retrieveAuthenticatedUserByHref(userHref: string): Observable<EPerson> { public retrieveAuthenticatedUserByHref(userHref: string): Observable<EPerson> {
return this.epersonService.findByHref(userHref).pipe( return this.epersonService.findByHref(userHref).pipe(
getFirstSucceededRemoteDataPayload() getAllSucceededRemoteDataPayload()
) )
} }

View File

@@ -51,6 +51,14 @@ describe('ServerSyncBufferEffects', () => {
_links: { self: { href: link } } _links: { self: { href: link } }
}); });
return observableOf(object); return observableOf(object);
},
getBySelfLink: (link) => {
const object = Object.assign(new DSpaceObject(), {
_links: {
self: { href: link }
}
});
return observableOf(object);
} }
} }
}, },

View File

@@ -16,13 +16,15 @@ import { Action, createSelector, MemoizedSelector, select, Store } from '@ngrx/s
import { ServerSyncBufferEntry, ServerSyncBufferState } from './server-sync-buffer.reducer'; import { ServerSyncBufferEntry, ServerSyncBufferState } from './server-sync-buffer.reducer';
import { combineLatest as observableCombineLatest, of as observableOf } from 'rxjs'; import { combineLatest as observableCombineLatest, of as observableOf } from 'rxjs';
import { RequestService } from '../data/request.service'; import { RequestService } from '../data/request.service';
import { PutRequest } from '../data/request.models'; import { PatchRequest, PutRequest } from '../data/request.models';
import { ObjectCacheService } from './object-cache.service'; import { ObjectCacheService } from './object-cache.service';
import { ApplyPatchObjectCacheAction } from './object-cache.actions'; import { ApplyPatchObjectCacheAction } from './object-cache.actions';
import { GenericConstructor } from '../shared/generic-constructor'; import { GenericConstructor } from '../shared/generic-constructor';
import { hasValue, isNotEmpty, isNotUndefined } from '../../shared/empty.util'; import { hasValue, isNotEmpty, isNotUndefined } from '../../shared/empty.util';
import { Observable } from 'rxjs/internal/Observable'; import { Observable } from 'rxjs/internal/Observable';
import { RestRequestMethod } from '../data/rest-request-method'; import { RestRequestMethod } from '../data/rest-request-method';
import { ObjectCacheEntry } from './object-cache.reducer';
import { Operation } from 'fast-json-patch';
@Injectable() @Injectable()
export class ServerSyncBufferEffects { export class ServerSyncBufferEffects {
@@ -96,17 +98,19 @@ export class ServerSyncBufferEffects {
* @returns {Observable<Action>} ApplyPatchObjectCacheAction to be dispatched * @returns {Observable<Action>} ApplyPatchObjectCacheAction to be dispatched
*/ */
private applyPatch(href: string): Observable<Action> { private applyPatch(href: string): Observable<Action> {
const patchObject = this.objectCache.getObjectBySelfLink(href).pipe(take(1)); const patchObject = this.objectCache.getBySelfLink(href).pipe(take(1));
return patchObject.pipe( return patchObject.pipe(
map((object) => { map((entry: ObjectCacheEntry) => {
const serializedObject = new DSpaceSerializer(object.constructor as GenericConstructor<{}>).serialize(object); if (isNotEmpty(entry.patches)) {
const flatPatch: Operation[] = [].concat(...entry.patches.map((patch) => patch.operations));
this.requestService.configure(new PutRequest(this.requestService.generateRequestId(), href, serializedObject)); if (isNotEmpty(flatPatch)) {
this.requestService.configure(new PatchRequest(this.requestService.generateRequestId(), href, flatPatch));
return new ApplyPatchObjectCacheAction(href) }
}
return new ApplyPatchObjectCacheAction(href);
}) })
) );
} }
constructor(private actions$: Actions, constructor(private actions$: Actions,

View File

@@ -16,8 +16,10 @@ import { HALEndpointService } from '../shared/hal-endpoint.service';
import { Item } from '../shared/item.model'; import { Item } from '../shared/item.model';
import { ChangeAnalyzer } from './change-analyzer'; import { ChangeAnalyzer } from './change-analyzer';
import { DataService } from './data.service'; import { DataService } from './data.service';
import { FindListOptions } from './request.models'; import { FindListOptions, PatchRequest } from './request.models';
import { RequestService } from './request.service'; import { RequestService } from './request.service';
import { getMockRequestService } from '../../shared/mocks/mock-request.service';
import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service-stub';
const endpoint = 'https://rest.api/core'; const endpoint = 'https://rest.api/core';
@@ -53,8 +55,8 @@ class DummyChangeAnalyzer implements ChangeAnalyzer<Item> {
describe('DataService', () => { describe('DataService', () => {
let service: TestService; let service: TestService;
let options: FindListOptions; let options: FindListOptions;
const requestService = { generateRequestId: () => uuidv4() } as RequestService; const requestService = getMockRequestService();
const halService = {} as HALEndpointService; const halService = new HALEndpointServiceStub('url') as any;
const rdbService = {} as RemoteDataBuildService; const rdbService = {} as RemoteDataBuildService;
const notificationsService = {} as NotificationsService; const notificationsService = {} as NotificationsService;
const http = {} as HttpClient; const http = {} as HttpClient;
@@ -285,18 +287,23 @@ describe('DataService', () => {
}); });
describe('patch', () => { describe('patch', () => {
let operations; const dso = {
let selfLink; uuid: 'dso-uuid'
};
const operations = [
Object.assign({
op: 'move',
from: '/1',
path: '/5'
}) as Operation
];
beforeEach(() => { beforeEach(() => {
operations = [{ op: 'replace', path: '/metadata/dc.title', value: 'random string' } as Operation]; service.patch(dso, operations);
selfLink = 'https://rest.api/endpoint/1698f1d3-be98-4c51-9fd8-6bfedcbd59b7';
spyOn(objectCache, 'addPatch');
}); });
it('should call addPatch on the object cache with the right parameters', () => { it('should configure a PatchRequest', () => {
service.patch(selfLink, operations); expect(requestService.configure).toHaveBeenCalledWith(jasmine.any(PatchRequest));
expect(objectCache.addPatch).toHaveBeenCalledWith(selfLink, operations);
}); });
}); });

View File

@@ -44,7 +44,7 @@ import {
FindByIDRequest, FindByIDRequest,
FindListOptions, FindListOptions,
FindListRequest, FindListRequest,
GetRequest GetRequest, PatchRequest
} from './request.models'; } from './request.models';
import { RequestEntry } from './request.reducer'; import { RequestEntry } from './request.reducer';
import { RequestService } from './request.service'; import { RequestService } from './request.service';
@@ -329,12 +329,28 @@ export abstract class DataService<T extends CacheableObject> {
} }
/** /**
* Add a new patch to the object cache to a specified object * Send a patch request for a specified object
* @param {string} href The selflink of the object that will be patched * @param {T} dso The object to send a patch request for
* @param {Operation[]} operations The patch operations to be performed * @param {Operation[]} operations The patch operations to be performed
*/ */
patch(href: string, operations: Operation[]) { patch(dso: T, operations: Operation[]): Observable<RestResponse> {
this.objectCache.addPatch(href, operations); const requestId = this.requestService.generateRequestId();
const hrefObs = this.halService.getEndpoint(this.linkPath).pipe(
map((endpoint: string) => this.getIDHref(endpoint, dso.uuid)));
hrefObs.pipe(
find((href: string) => hasValue(href)),
map((href: string) => {
const request = new PatchRequest(requestId, href, operations);
this.requestService.configure(request);
})
).subscribe();
return this.requestService.getByUUID(requestId).pipe(
find((request: RequestEntry) => request.completed),
map((request: RequestEntry) => request.response)
);
} }
/** /**

View File

@@ -3,6 +3,8 @@ import { compare } from 'fast-json-patch';
import { ChangeAnalyzer } from './change-analyzer'; import { ChangeAnalyzer } from './change-analyzer';
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { DSpaceObject } from '../shared/dspace-object.model'; import { DSpaceObject } from '../shared/dspace-object.model';
import { MetadataMap } from '../shared/metadata.models';
import { cloneDeep } from 'lodash';
/** /**
* A class to determine what differs between two * A class to determine what differs between two
@@ -21,6 +23,21 @@ export class DSOChangeAnalyzer<T extends DSpaceObject> implements ChangeAnalyzer
* The second object to compare * The second object to compare
*/ */
diff(object1: DSpaceObject, object2: DSpaceObject): Operation[] { diff(object1: DSpaceObject, object2: DSpaceObject): Operation[] {
return compare(object1.metadata, object2.metadata).map((operation: Operation) => Object.assign({}, operation, { path: '/metadata' + operation.path })); return compare(this.filterUUIDsFromMetadata(object1.metadata), this.filterUUIDsFromMetadata(object2.metadata))
.map((operation: Operation) => Object.assign({}, operation, { path: '/metadata' + operation.path }));
}
/**
* Filter the UUIDs out of a MetadataMap
* @param metadata
*/
filterUUIDsFromMetadata(metadata: MetadataMap): MetadataMap {
const result = cloneDeep(metadata);
for (const key of Object.keys(result)) {
for (const metadataValue of result[key]) {
metadataValue.uuid = undefined;
}
}
return result;
} }
} }

View File

@@ -119,6 +119,8 @@ export class HeadRequest extends RestRequest {
} }
export class PatchRequest extends RestRequest { export class PatchRequest extends RestRequest {
public responseMsToLive = 60 * 15 * 1000;
constructor( constructor(
public uuid: string, public uuid: string,
public href: string, public href: string,

View File

@@ -20,7 +20,7 @@ import { EPERSON } from './models/eperson.resource-type';
@dataService(EPERSON) @dataService(EPERSON)
export class EPersonDataService extends DataService<EPerson> { export class EPersonDataService extends DataService<EPerson> {
protected linkPath: 'epersons'; protected linkPath = 'epersons';
constructor( constructor(
protected requestService: RequestService, protected requestService: RequestService,

View File

@@ -0,0 +1,6 @@
<ds-form *ngIf="formModel"
[formId]="'profile-page-metadata-form-id'"
[formModel]="formModel"
[formGroup]="formGroup"
[displaySubmit]="false">
</ds-form>

View File

@@ -0,0 +1,142 @@
import { ProfilePageMetadataFormComponent } from './profile-page-metadata-form.component';
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { VarDirective } from '../../shared/utils/var.directive';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { RouterTestingModule } from '@angular/router/testing';
import { EventEmitter, NO_ERRORS_SCHEMA } from '@angular/core';
import { EPerson } from '../../core/eperson/models/eperson.model';
import { GLOBAL_CONFIG } from '../../../config';
import { FormBuilderService } from '../../shared/form/builder/form-builder.service';
import { NotificationsService } from '../../shared/notifications/notifications.service';
import { EPersonDataService } from '../../core/eperson/eperson-data.service';
import { cloneDeep } from 'lodash';
import { createSuccessfulRemoteDataObject$ } from '../../shared/testing/utils';
describe('ProfilePageMetadataFormComponent', () => {
let component: ProfilePageMetadataFormComponent;
let fixture: ComponentFixture<ProfilePageMetadataFormComponent>;
const config = {
languages: [{
code: 'en',
label: 'English',
active: true,
}, {
code: 'de',
label: 'Deutsch',
active: true,
}]
} as any;
const user = Object.assign(new EPerson(), {
email: 'example@gmail.com',
metadata: {
'eperson.firstname': [
{
value: 'John',
language: null
}
],
'eperson.lastname': [
{
value: 'Doe',
language: null
}
],
'eperson.language': [
{
value: 'de',
language: null
}
]
}
});
const epersonService = jasmine.createSpyObj('epersonService', {
update: createSuccessfulRemoteDataObject$(user)
});
const notificationsService = jasmine.createSpyObj('notificationsService', {
success: {},
error: {},
warning: {}
});
const translate = {
instant: () => 'translated',
onLangChange: new EventEmitter()
};
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [ProfilePageMetadataFormComponent, VarDirective],
imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([])],
providers: [
{ provide: GLOBAL_CONFIG, useValue: config },
{ provide: EPersonDataService, useValue: epersonService },
{ provide: TranslateService, useValue: translate },
{ provide: NotificationsService, useValue: notificationsService },
FormBuilderService
],
schemas: [NO_ERRORS_SCHEMA]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(ProfilePageMetadataFormComponent);
component = fixture.componentInstance;
component.user = user;
fixture.detectChanges();
});
it('should automatically fill in the user\'s email in the correct field', () => {
expect(component.formGroup.get('email').value).toEqual(user.email);
});
it('should automatically fill the present metadata values and leave missing ones empty', () => {
expect(component.formGroup.get('firstname').value).toEqual('John');
expect(component.formGroup.get('lastname').value).toEqual('Doe');
expect(component.formGroup.get('phone').value).toBeUndefined();
expect(component.formGroup.get('language').value).toEqual('de');
});
describe('updateProfile', () => {
describe('when no values changed', () => {
let result;
beforeEach(() => {
result = component.updateProfile();
});
it('should return false', () => {
expect(result).toEqual(false);
});
it('should not call epersonService.update', () => {
expect(epersonService.update).not.toHaveBeenCalled();
});
});
describe('when a form value changed', () => {
let result;
let newUser;
beforeEach(() => {
newUser = cloneDeep(user);
newUser.metadata['eperson.firstname'][0].value = 'Johnny';
setModelValue('firstname', 'Johnny');
result = component.updateProfile();
});
it('should return true', () => {
expect(result).toEqual(true);
});
it('should call epersonService.update', () => {
expect(epersonService.update).toHaveBeenCalledWith(newUser);
});
});
});
function setModelValue(id: string, value: string) {
component.formModel.filter((model) => model.id === id).forEach((model) => (model as any).value = value);
}
});

View File

@@ -0,0 +1,212 @@
import { Component, Inject, Input, OnInit } from '@angular/core';
import {
DynamicFormControlModel,
DynamicFormService, DynamicFormValueControlModel,
DynamicInputModel, DynamicSelectModel
} from '@ng-dynamic-forms/core';
import { FormGroup } from '@angular/forms';
import { EPerson } from '../../core/eperson/models/eperson.model';
import { TranslateService } from '@ngx-translate/core';
import { hasValue, isNotEmpty } from '../../shared/empty.util';
import { GLOBAL_CONFIG, GlobalConfig } from '../../../config';
import { LangConfig } from '../../../config/lang-config.interface';
import { EPersonDataService } from '../../core/eperson/eperson-data.service';
import { cloneDeep } from 'lodash';
import { getRemoteDataPayload, getSucceededRemoteData } from '../../core/shared/operators';
import { FormBuilderService } from '../../shared/form/builder/form-builder.service';
import { NotificationsService } from '../../shared/notifications/notifications.service';
@Component({
selector: 'ds-profile-page-metadata-form',
templateUrl: './profile-page-metadata-form.component.html'
})
/**
* Component for a user to edit their metadata
* Displays a form containing:
* - readonly email field,
* - required first name text field
* - required last name text field
* - phone text field
* - language dropdown
*/
export class ProfilePageMetadataFormComponent implements OnInit {
/**
* The user to display the form for
*/
@Input() user: EPerson;
/**
* The form's input models
*/
formModel: DynamicFormControlModel[] = [
new DynamicInputModel({
id: 'email',
name: 'email',
readOnly: true
}),
new DynamicInputModel({
id: 'firstname',
name: 'eperson.firstname',
required: true,
validators: {
required: null
},
errorMessages: {
required: 'This field is required'
},
}),
new DynamicInputModel({
id: 'lastname',
name: 'eperson.lastname',
required: true,
validators: {
required: null
},
errorMessages: {
required: 'This field is required'
},
}),
new DynamicInputModel({
id: 'phone',
name: 'eperson.phone'
}),
new DynamicSelectModel<string>({
id: 'language',
name: 'eperson.language'
})
];
/**
* The form group of this form
*/
formGroup: FormGroup;
/**
* Prefix for the form's label messages of this component
*/
LABEL_PREFIX = 'profile.metadata.form.label.';
/**
* Prefix for the form's error messages of this component
*/
ERROR_PREFIX = 'profile.metadata.form.error.';
/**
* Prefix for the notification messages of this component
*/
NOTIFICATION_PREFIX = 'profile.metadata.form.notifications.';
/**
* All of the configured active languages
* Used to populate the language dropdown
*/
activeLangs: LangConfig[];
constructor(@Inject(GLOBAL_CONFIG) protected config: GlobalConfig,
protected formBuilderService: FormBuilderService,
protected translate: TranslateService,
protected epersonService: EPersonDataService,
protected notificationsService: NotificationsService) {
}
ngOnInit(): void {
this.activeLangs = this.config.languages.filter((MyLangConfig) => MyLangConfig.active === true);
this.setFormValues();
this.updateFieldTranslations();
this.translate.onLangChange
.subscribe(() => {
this.updateFieldTranslations();
});
}
/**
* Loop over all the form's input models and set their values depending on the user's metadata
* Create the FormGroup
*/
setFormValues() {
this.formModel.forEach(
(fieldModel: DynamicInputModel | DynamicSelectModel<string>) => {
if (fieldModel.name === 'email') {
fieldModel.value = this.user.email;
} else {
fieldModel.value = this.user.firstMetadataValue(fieldModel.name);
}
if (fieldModel.id === 'language') {
(fieldModel as DynamicSelectModel<string>).options =
this.activeLangs.map((langConfig) => Object.assign({ value: langConfig.code, label: langConfig.label }))
}
}
);
this.formGroup = this.formBuilderService.createFormGroup(this.formModel);
}
/**
* Update the translations of the field labels and error messages
*/
updateFieldTranslations() {
this.formModel.forEach(
(fieldModel: DynamicInputModel) => {
fieldModel.label = this.translate.instant(this.LABEL_PREFIX + fieldModel.id);
if (isNotEmpty(fieldModel.validators)) {
fieldModel.errorMessages = {};
Object.keys(fieldModel.validators).forEach((key) => {
fieldModel.errorMessages[key] = this.translate.instant(this.ERROR_PREFIX + fieldModel.id + '.' + key);
});
}
}
);
}
/**
* Update the user's metadata
*
* Sends a patch request for updating the user's metadata when at least one value changed or got added/removed and the
* form is valid.
* Nothing happens when the form is invalid or no metadata changed.
*
* Returns false when nothing happened.
*/
updateProfile(): boolean {
if (!this.formGroup.valid) {
return false;
}
const newMetadata = cloneDeep(this.user.metadata);
let changed = false;
this.formModel.filter((fieldModel) => fieldModel.id !== 'email').forEach((fieldModel: DynamicFormValueControlModel<string>) => {
if (newMetadata.hasOwnProperty(fieldModel.name) && newMetadata[fieldModel.name].length > 0) {
if (hasValue(fieldModel.value)) {
if (newMetadata[fieldModel.name][0].value !== fieldModel.value) {
newMetadata[fieldModel.name][0].value = fieldModel.value;
changed = true;
}
} else {
newMetadata[fieldModel.name] = [];
changed = true;
}
} else if (hasValue(fieldModel.value)) {
newMetadata[fieldModel.name] = [{
value: fieldModel.value,
language: null
} as any];
changed = true;
}
});
if (changed) {
this.epersonService.update(Object.assign(cloneDeep(this.user), {metadata: newMetadata})).pipe(
getSucceededRemoteData(),
getRemoteDataPayload()
).subscribe((user) => {
this.user = user;
this.setFormValues();
this.notificationsService.success(
this.translate.instant(this.NOTIFICATION_PREFIX + 'success.title'),
this.translate.instant(this.NOTIFICATION_PREFIX + 'success.content')
);
});
}
return changed;
}
}

View File

@@ -0,0 +1,15 @@
import { NgModule } from '@angular/core';
import { RouterModule } from '@angular/router';
import { I18nBreadcrumbResolver } from '../core/breadcrumbs/i18n-breadcrumb.resolver';
import { ProfilePageComponent } from './profile-page.component';
@NgModule({
imports: [
RouterModule.forChild([
{ path: '', pathMatch: 'full', component: ProfilePageComponent, resolve: { breadcrumb: I18nBreadcrumbResolver }, data: { breadcrumbKey: 'profile', title: 'profile.title' } }
])
]
})
export class ProfilePageRoutingModule {
}

View File

@@ -0,0 +1,9 @@
<div class="container-fluid mb-4">{{'profile.security.form.info' | translate}}</div>
<ds-form *ngIf="formModel"
[formId]="'profile-page-security-form-id'"
[formModel]="formModel"
[formGroup]="formGroup"
[displaySubmit]="false">
</ds-form>
<div class="container-fluid text-danger" *ngIf="formGroup.hasError('notLongEnough')">{{'profile.security.form.error.password-length' | translate}}</div>
<div class="container-fluid text-danger" *ngIf="formGroup.hasError('notSame')">{{'profile.security.form.error.matching-passwords' | translate}}</div>

View File

@@ -0,0 +1,110 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { EPerson } from '../../core/eperson/models/eperson.model';
import { NO_ERRORS_SCHEMA } from '@angular/core';
import { VarDirective } from '../../shared/utils/var.directive';
import { TranslateModule } from '@ngx-translate/core';
import { RouterTestingModule } from '@angular/router/testing';
import { EPersonDataService } from '../../core/eperson/eperson-data.service';
import { NotificationsService } from '../../shared/notifications/notifications.service';
import { FormBuilderService } from '../../shared/form/builder/form-builder.service';
import { ProfilePageSecurityFormComponent } from './profile-page-security-form.component';
import { of as observableOf } from 'rxjs';
import { RestResponse } from '../../core/cache/response.models';
describe('ProfilePageSecurityFormComponent', () => {
let component: ProfilePageSecurityFormComponent;
let fixture: ComponentFixture<ProfilePageSecurityFormComponent>;
const user = Object.assign(new EPerson(), {
_links: {
self: { href: 'user-selflink' }
}
});
const epersonService = jasmine.createSpyObj('epersonService', {
patch: observableOf(new RestResponse(true, 200, 'OK'))
});
const notificationsService = jasmine.createSpyObj('notificationsService', {
success: {},
error: {},
warning: {}
});
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [ProfilePageSecurityFormComponent, VarDirective],
imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([])],
providers: [
{ provide: EPersonDataService, useValue: epersonService },
{ provide: NotificationsService, useValue: notificationsService },
FormBuilderService
],
schemas: [NO_ERRORS_SCHEMA]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(ProfilePageSecurityFormComponent);
component = fixture.componentInstance;
component.user = user;
fixture.detectChanges();
});
describe('updateSecurity', () => {
describe('when no values changed', () => {
let result;
beforeEach(() => {
result = component.updateSecurity();
});
it('should return false', () => {
expect(result).toEqual(false);
});
it('should not call epersonService.patch', () => {
expect(epersonService.patch).not.toHaveBeenCalled();
});
});
describe('when password is filled in, but the confirm field is empty', () => {
let result;
beforeEach(() => {
setModelValue('password', 'test');
result = component.updateSecurity();
});
it('should return true', () => {
expect(result).toEqual(true);
});
});
describe('when both password fields are filled in, long enough and equal', () => {
let result;
let operations;
beforeEach(() => {
setModelValue('password', 'testest');
setModelValue('passwordrepeat', 'testest');
operations = [{ op: 'replace', path: '/password', value: 'testest' }];
result = component.updateSecurity();
});
it('should return true', () => {
expect(result).toEqual(true);
});
it('should return call epersonService.patch', () => {
expect(epersonService.patch).toHaveBeenCalledWith(user, operations);
});
});
});
function setModelValue(id: string, value: string) {
component.formGroup.patchValue({
[id]: value
});
component.formGroup.markAllAsTouched();
}
});

View File

@@ -0,0 +1,151 @@
import { Component, Input, OnInit } from '@angular/core';
import {
DynamicFormControlModel,
DynamicFormService,
DynamicInputModel
} from '@ng-dynamic-forms/core';
import { TranslateService } from '@ngx-translate/core';
import { FormGroup } from '@angular/forms';
import { isEmpty, isNotEmpty } from '../../shared/empty.util';
import { EPersonDataService } from '../../core/eperson/eperson-data.service';
import { EPerson } from '../../core/eperson/models/eperson.model';
import { ErrorResponse, RestResponse } from '../../core/cache/response.models';
import { NotificationsService } from '../../shared/notifications/notifications.service';
@Component({
selector: 'ds-profile-page-security-form',
templateUrl: './profile-page-security-form.component.html'
})
/**
* Component for a user to edit their security information
* Displays a form containing a password field and a confirmation of the password
*/
export class ProfilePageSecurityFormComponent implements OnInit {
/**
* The user to display the form for
*/
@Input() user: EPerson;
/**
* The form's input models
*/
formModel: DynamicFormControlModel[] = [
new DynamicInputModel({
id: 'password',
name: 'password',
inputType: 'password'
}),
new DynamicInputModel({
id: 'passwordrepeat',
name: 'passwordrepeat',
inputType: 'password'
})
];
/**
* The form group of this form
*/
formGroup: FormGroup;
/**
* Prefix for the notification messages of this component
*/
NOTIFICATIONS_PREFIX = 'profile.security.form.notifications.';
/**
* Prefix for the form's label messages of this component
*/
LABEL_PREFIX = 'profile.security.form.label.';
constructor(protected formService: DynamicFormService,
protected translate: TranslateService,
protected epersonService: EPersonDataService,
protected notificationsService: NotificationsService) {
}
ngOnInit(): void {
this.formGroup = this.formService.createFormGroup(this.formModel, { validators: [this.checkPasswordsEqual, this.checkPasswordLength] });
this.updateFieldTranslations();
this.translate.onLangChange
.subscribe(() => {
this.updateFieldTranslations();
});
}
/**
* Update the translations of the field labels
*/
updateFieldTranslations() {
this.formModel.forEach(
(fieldModel: DynamicInputModel) => {
fieldModel.label = this.translate.instant(this.LABEL_PREFIX + fieldModel.id);
}
);
}
/**
* Check if both password fields are filled in and equal
* @param group The FormGroup to validate
*/
checkPasswordsEqual(group: FormGroup) {
const pass = group.get('password').value;
const repeatPass = group.get('passwordrepeat').value;
return pass === repeatPass ? null : { notSame: true };
}
/**
* Check if the password is at least 6 characters long
* @param group The FormGroup to validate
*/
checkPasswordLength(group: FormGroup) {
const pass = group.get('password').value;
return isEmpty(pass) || pass.length >= 6 ? null : { notLongEnough: true };
}
/**
* Update the user's security details
*
* Sends a patch request for changing the user's password when a new password is present and the password confirmation
* matches the new password.
* Nothing happens when no passwords are filled in.
* An error notification is displayed when the password confirmation does not match the new password.
*
* Returns false when nothing happened
*/
updateSecurity() {
const pass = this.formGroup.get('password').value;
const passEntered = isNotEmpty(pass);
if (!this.formGroup.valid) {
if (passEntered) {
if (this.checkPasswordsEqual(this.formGroup) != null) {
this.notificationsService.error(this.translate.instant(this.NOTIFICATIONS_PREFIX + 'error.not-same'));
}
if (this.checkPasswordLength(this.formGroup) != null) {
this.notificationsService.error(this.translate.instant(this.NOTIFICATIONS_PREFIX + 'error.not-long-enough'));
}
return true;
}
return false;
}
if (passEntered) {
const operation = Object.assign({ op: 'replace', path: '/password', value: pass });
this.epersonService.patch(this.user, [operation]).subscribe((response: RestResponse) => {
if (response.isSuccessful) {
this.notificationsService.success(
this.translate.instant(this.NOTIFICATIONS_PREFIX + 'success.title'),
this.translate.instant(this.NOTIFICATIONS_PREFIX + 'success.content')
);
} else {
this.notificationsService.error(
this.translate.instant(this.NOTIFICATIONS_PREFIX + 'error.title'), (response as ErrorResponse).errorMessage
);
}
});
}
return passEntered;
}
}

View File

@@ -0,0 +1,27 @@
<ng-container *ngVar="(user$ | async) as user">
<div class="container" *ngIf="user">
<h3 class="mb-4">{{'profile.head' | translate}}</h3>
<div class="card mb-4">
<div class="card-header">{{'profile.card.identify' | translate}}</div>
<div class="card-body">
<ds-profile-page-metadata-form [user]="user"></ds-profile-page-metadata-form>
</div>
</div>
<div class="card mb-4">
<div class="card-header">{{'profile.card.security' | translate}}</div>
<div class="card-body">
<ds-profile-page-security-form [user]="user"></ds-profile-page-security-form>
</div>
</div>
<button class="btn btn-outline-primary" (click)="updateProfile()">{{'profile.form.submit' | translate}}</button>
<ng-container *ngVar="(groupsRD$ | async)?.payload?.page as groups">
<div *ngIf="groups">
<h3 class="mt-4">{{'profile.groups.head' | translate}}</h3>
<ul class="list-group list-group-flush">
<li *ngFor="let group of groups" class="list-group-item">{{group.name}}</li>
</ul>
</div>
</ng-container>
</div>
</ng-container>

View File

@@ -0,0 +1,129 @@
import { ProfilePageComponent } from './profile-page.component';
import { async, ComponentFixture, inject, TestBed } from '@angular/core/testing';
import { VarDirective } from '../shared/utils/var.directive';
import { TranslateModule } from '@ngx-translate/core';
import { RouterTestingModule } from '@angular/router/testing';
import { NO_ERRORS_SCHEMA } from '@angular/core';
import { EPerson } from '../core/eperson/models/eperson.model';
import { createPaginatedList, createSuccessfulRemoteDataObject$ } from '../shared/testing/utils';
import { Store, StoreModule } from '@ngrx/store';
import { AppState } from '../app.reducer';
import { AuthTokenInfo } from '../core/auth/models/auth-token-info.model';
import { EPersonDataService } from '../core/eperson/eperson-data.service';
import { NotificationsService } from '../shared/notifications/notifications.service';
import { authReducer } from '../core/auth/auth.reducer';
describe('ProfilePageComponent', () => {
let component: ProfilePageComponent;
let fixture: ComponentFixture<ProfilePageComponent>;
const user = Object.assign(new EPerson(), {
groups: createSuccessfulRemoteDataObject$(createPaginatedList([]))
});
const authState = {
authenticated: true,
loaded: true,
loading: false,
authToken: new AuthTokenInfo('test_token'),
user: user
};
const epersonService = jasmine.createSpyObj('epersonService', {
findById: createSuccessfulRemoteDataObject$(user)
});
const notificationsService = jasmine.createSpyObj('notificationsService', {
success: {},
error: {},
warning: {}
});
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [ProfilePageComponent, VarDirective],
imports: [StoreModule.forRoot(authReducer), TranslateModule.forRoot(), RouterTestingModule.withRoutes([])],
providers: [
{ provide: EPersonDataService, useValue: epersonService },
{ provide: NotificationsService, useValue: notificationsService }
],
schemas: [NO_ERRORS_SCHEMA]
}).compileComponents();
}));
beforeEach(inject([Store], (store: Store<AppState>) => {
store
.subscribe((state) => {
(state as any).core = Object.create({});
(state as any).core.auth = authState;
});
fixture = TestBed.createComponent(ProfilePageComponent);
component = fixture.componentInstance;
fixture.detectChanges();
}));
describe('updateProfile', () => {
describe('when the metadata form returns false and the security form returns true', () => {
beforeEach(() => {
component.metadataForm = jasmine.createSpyObj('metadataForm', {
updateProfile: false
});
component.securityForm = jasmine.createSpyObj('securityForm', {
updateSecurity: true
});
component.updateProfile();
});
it('should not display a warning', () => {
expect(notificationsService.warning).not.toHaveBeenCalled();
});
});
describe('when the metadata form returns true and the security form returns false', () => {
beforeEach(() => {
component.metadataForm = jasmine.createSpyObj('metadataForm', {
updateProfile: true
});
component.securityForm = jasmine.createSpyObj('securityForm', {
updateSecurity: false
});
component.updateProfile();
});
it('should not display a warning', () => {
expect(notificationsService.warning).not.toHaveBeenCalled();
});
});
describe('when the metadata form returns true and the security form returns true', () => {
beforeEach(() => {
component.metadataForm = jasmine.createSpyObj('metadataForm', {
updateProfile: true
});
component.securityForm = jasmine.createSpyObj('securityForm', {
updateSecurity: true
});
component.updateProfile();
});
it('should not display a warning', () => {
expect(notificationsService.warning).not.toHaveBeenCalled();
});
});
describe('when the metadata form returns false and the security form returns false', () => {
beforeEach(() => {
component.metadataForm = jasmine.createSpyObj('metadataForm', {
updateProfile: false
});
component.securityForm = jasmine.createSpyObj('securityForm', {
updateSecurity: false
});
component.updateProfile();
});
it('should display a warning', () => {
expect(notificationsService.warning).toHaveBeenCalled();
});
});
});
});

View File

@@ -0,0 +1,84 @@
import { Component, OnInit, ViewChild } from '@angular/core';
import { Observable } from 'rxjs/internal/Observable';
import { EPerson } from '../core/eperson/models/eperson.model';
import { select, Store } from '@ngrx/store';
import { getAuthenticatedUser } from '../core/auth/selectors';
import { AppState } from '../app.reducer';
import { ProfilePageMetadataFormComponent } from './profile-page-metadata-form/profile-page-metadata-form.component';
import { ProfilePageSecurityFormComponent } from './profile-page-security-form/profile-page-security-form.component';
import { NotificationsService } from '../shared/notifications/notifications.service';
import { TranslateService } from '@ngx-translate/core';
import { Group } from '../core/eperson/models/group.model';
import { RemoteData } from '../core/data/remote-data';
import { PaginatedList } from '../core/data/paginated-list';
import { filter, switchMap, tap } from 'rxjs/operators';
import { EPersonDataService } from '../core/eperson/eperson-data.service';
import { getAllSucceededRemoteData, getRemoteDataPayload, getSucceededRemoteData } from '../core/shared/operators';
import { hasValue } from '../shared/empty.util';
import { followLink } from '../shared/utils/follow-link-config.model';
@Component({
selector: 'ds-profile-page',
templateUrl: './profile-page.component.html'
})
/**
* Component for a user to edit their profile information
*/
export class ProfilePageComponent implements OnInit {
/**
* A reference to the metadata form component
*/
@ViewChild(ProfilePageMetadataFormComponent, { static: false }) metadataForm: ProfilePageMetadataFormComponent;
/**
* A reference to the security form component
*/
@ViewChild(ProfilePageSecurityFormComponent, { static: false }) securityForm: ProfilePageSecurityFormComponent;
/**
* The authenticated user
*/
user$: Observable<EPerson>;
/**
* The groups the user belongs to
*/
groupsRD$: Observable<RemoteData<PaginatedList<Group>>>;
/**
* Prefix for the notification messages of this component
*/
NOTIFICATIONS_PREFIX = 'profile.notifications.';
constructor(private store: Store<AppState>,
private notificationsService: NotificationsService,
private translate: TranslateService,
private epersonService: EPersonDataService) {
}
ngOnInit(): void {
this.user$ = this.store.pipe(
select(getAuthenticatedUser),
filter((user: EPerson) => hasValue(user.id)),
switchMap((user: EPerson) => this.epersonService.findById(user.id, followLink('groups'))),
getAllSucceededRemoteData(),
getRemoteDataPayload()
);
this.groupsRD$ = this.user$.pipe(switchMap((user: EPerson) => user.groups));
}
/**
* Fire an update on both the metadata and security forms
* Show a warning notification when no changes were made in both forms
*/
updateProfile() {
const metadataChanged = this.metadataForm.updateProfile();
const securityChanged = this.securityForm.updateSecurity();
if (!metadataChanged && !securityChanged) {
this.notificationsService.warning(
this.translate.instant(this.NOTIFICATIONS_PREFIX + 'warning.no-changes.title'),
this.translate.instant(this.NOTIFICATIONS_PREFIX + 'warning.no-changes.content')
);
}
}
}

View File

@@ -0,0 +1,23 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { SharedModule } from '../shared/shared.module';
import { ProfilePageRoutingModule } from './profile-page-routing.module';
import { ProfilePageComponent } from './profile-page.component';
import { ProfilePageMetadataFormComponent } from './profile-page-metadata-form/profile-page-metadata-form.component';
import { ProfilePageSecurityFormComponent } from './profile-page-security-form/profile-page-security-form.component';
@NgModule({
imports: [
ProfilePageRoutingModule,
CommonModule,
SharedModule
],
declarations: [
ProfilePageComponent,
ProfilePageMetadataFormComponent,
ProfilePageSecurityFormComponent
]
})
export class ProfilePageModule {
}

View File

@@ -1,6 +1,7 @@
<ds-loading *ngIf="(loading$ | async)"></ds-loading> <ds-loading *ngIf="(loading$ | async)"></ds-loading>
<div *ngIf="!(loading$ | async)"> <div *ngIf="!(loading$ | async)">
<span class="dropdown-item-text">{{(user$ | async)?.name}} ({{(user$ | async)?.email}})</span> <span class="dropdown-item-text">{{(user$ | async)?.name}} ({{(user$ | async)?.email}})</span>
<a class="dropdown-item" [routerLink]="[profileRoute]" routerLinkActive="active">{{'nav.profile' | translate}}</a>
<a class="dropdown-item" [routerLink]="[mydspaceRoute]" routerLinkActive="active">{{'nav.mydspace' | translate}}</a> <a class="dropdown-item" [routerLink]="[mydspaceRoute]" routerLinkActive="active">{{'nav.mydspace' | translate}}</a>
<div class="dropdown-divider"></div> <div class="dropdown-divider"></div>
<ds-log-out></ds-log-out> <ds-log-out></ds-log-out>

View File

@@ -7,6 +7,7 @@ import { EPerson } from '../../../core/eperson/models/eperson.model';
import { AppState } from '../../../app.reducer'; import { AppState } from '../../../app.reducer';
import { getAuthenticatedUser, isAuthenticationLoading } from '../../../core/auth/selectors'; import { getAuthenticatedUser, isAuthenticationLoading } from '../../../core/auth/selectors';
import { MYDSPACE_ROUTE } from '../../../+my-dspace-page/my-dspace-page.component'; import { MYDSPACE_ROUTE } from '../../../+my-dspace-page/my-dspace-page.component';
import { getProfileModulePath } from '../../../app-routing.module';
/** /**
* This component represents the user nav menu. * This component represents the user nav menu.
@@ -36,6 +37,11 @@ export class UserMenuComponent implements OnInit {
*/ */
public mydspaceRoute = MYDSPACE_ROUTE; public mydspaceRoute = MYDSPACE_ROUTE;
/**
* The profile page route
*/
public profileRoute = getProfileModulePath();
constructor(private store: Store<AppState>) { constructor(private store: Store<AppState>) {
} }