Merge pull request #687 from atmire/w2p-70504_New-user-registration

New User Registration
This commit is contained in:
Tim Donohue
2020-07-01 09:48:23 -05:00
committed by GitHub
31 changed files with 1185 additions and 103 deletions

View File

@@ -45,6 +45,13 @@ export function getProfileModulePath() {
return `/${PROFILE_MODULE_PATH}`;
}
const REGISTER_PATH = 'register';
export function getRegisterPath() {
return `/${REGISTER_PATH}`;
}
const WORKFLOW_ITEM_MODULE_PATH = 'workflowitems';
export function getWorkflowItemModulePath() {
@@ -71,6 +78,7 @@ export function getDSOPath(dso: DSpaceObject): string {
{ path: 'community-list', loadChildren: './community-list-page/community-list-page.module#CommunityListPageModule' },
{ path: 'id', loadChildren: './+lookup-by-id/lookup-by-id.module#LookupIdModule' },
{ path: 'handle', loadChildren: './+lookup-by-id/lookup-by-id.module#LookupIdModule' },
{ path: REGISTER_PATH, loadChildren: './register-page/register-page.module#RegisterPageModule' },
{ path: COMMUNITY_MODULE_PATH, loadChildren: './+community-page/community-page.module#CommunityPageModule' },
{ path: COLLECTION_MODULE_PATH, loadChildren: './+collection-page/collection-page.module#CollectionPageModule' },
{ path: ITEM_MODULE_PATH, loadChildren: './+item-page/item-page.module#ItemPageModule' },

View File

@@ -3,7 +3,6 @@ import { Action } from '@ngrx/store';
// import type function
import { type } from '../../shared/ngrx/type';
// import models
import { EPerson } from '../eperson/models/eperson.model';
import { AuthTokenInfo } from './models/auth-token-info.model';
import { AuthMethod } from './models/auth.method';
import { AuthStatus } from './models/auth-status.model';
@@ -31,9 +30,6 @@ export const AuthActionTypes = {
LOG_OUT: type('dspace/auth/LOG_OUT'),
LOG_OUT_ERROR: type('dspace/auth/LOG_OUT_ERROR'),
LOG_OUT_SUCCESS: type('dspace/auth/LOG_OUT_SUCCESS'),
REGISTRATION: type('dspace/auth/REGISTRATION'),
REGISTRATION_ERROR: type('dspace/auth/REGISTRATION_ERROR'),
REGISTRATION_SUCCESS: type('dspace/auth/REGISTRATION_SUCCESS'),
SET_REDIRECT_URL: type('dspace/auth/SET_REDIRECT_URL'),
RETRIEVE_AUTHENTICATED_EPERSON: type('dspace/auth/RETRIEVE_AUTHENTICATED_EPERSON'),
RETRIEVE_AUTHENTICATED_EPERSON_SUCCESS: type('dspace/auth/RETRIEVE_AUTHENTICATED_EPERSON_SUCCESS'),
@@ -263,48 +259,6 @@ export class RetrieveTokenAction implements Action {
public type: string = AuthActionTypes.RETRIEVE_TOKEN;
}
/**
* Sign up.
* @class RegistrationAction
* @implements {Action}
*/
export class RegistrationAction implements Action {
public type: string = AuthActionTypes.REGISTRATION;
payload: EPerson;
constructor(user: EPerson) {
this.payload = user;
}
}
/**
* Sign up error.
* @class RegistrationErrorAction
* @implements {Action}
*/
export class RegistrationErrorAction implements Action {
public type: string = AuthActionTypes.REGISTRATION_ERROR;
payload: Error;
constructor(payload: Error) {
this.payload = payload;
}
}
/**
* Sign up success.
* @class RegistrationSuccessAction
* @implements {Action}
*/
export class RegistrationSuccessAction implements Action {
public type: string = AuthActionTypes.REGISTRATION_SUCCESS;
payload: EPerson;
constructor(user: EPerson) {
this.payload = user;
}
}
/**
* Add uthentication message.
* @class AddAuthenticationMessageAction
@@ -439,9 +393,6 @@ export type AuthActions
| CheckAuthenticationTokenCookieAction
| RedirectWhenAuthenticationIsRequiredAction
| RedirectWhenTokenExpiredAction
| RegistrationAction
| RegistrationErrorAction
| RegistrationSuccessAction
| AddAuthenticationMessageAction
| RefreshTokenAction
| RefreshTokenErrorAction

View File

@@ -1,7 +1,7 @@
import { Injectable } from '@angular/core';
import { combineLatest as observableCombineLatest, Observable, of as observableOf } from 'rxjs';
import { catchError, debounceTime, filter, map, switchMap, take, tap } from 'rxjs/operators';
import { catchError, filter, map, switchMap, take, tap } from 'rxjs/operators';
// import @ngrx
import { Actions, Effect, ofType } from '@ngrx/effects';
import { Action, select, Store } from '@ngrx/store';
@@ -30,9 +30,6 @@ import {
RefreshTokenAction,
RefreshTokenErrorAction,
RefreshTokenSuccessAction,
RegistrationAction,
RegistrationErrorAction,
RegistrationSuccessAction,
RetrieveAuthenticatedEpersonAction,
RetrieveAuthenticatedEpersonErrorAction,
RetrieveAuthenticatedEpersonSuccessAction,
@@ -136,18 +133,6 @@ export class AuthEffects {
})
);
@Effect()
public createUser$: Observable<Action> = this.actions$.pipe(
ofType(AuthActionTypes.REGISTRATION),
debounceTime(500), // to remove when functionality is implemented
switchMap((action: RegistrationAction) => {
return this.authService.create(action.payload).pipe(
map((user: EPerson) => new RegistrationSuccessAction(user)),
catchError((error) => observableOf(new RegistrationErrorAction(error)))
);
})
);
@Effect()
public retrieveToken$: Observable<Action> = this.actions$.pipe(
ofType(AuthActionTypes.RETRIEVE_TOKEN),

View File

@@ -115,7 +115,6 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut
});
case AuthActionTypes.AUTHENTICATE_ERROR:
case AuthActionTypes.REGISTRATION_ERROR:
return Object.assign({}, state, {
authenticated: false,
authToken: undefined,
@@ -157,18 +156,6 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut
userId: undefined
});
case AuthActionTypes.REGISTRATION:
return Object.assign({}, state, {
authenticated: false,
authToken: undefined,
error: undefined,
loading: true,
info: undefined
});
case AuthActionTypes.REGISTRATION_SUCCESS:
return state;
case AuthActionTypes.REFRESH_TOKEN:
return Object.assign({}, state, {
refreshing: true,

View File

@@ -270,18 +270,6 @@ export class AuthService {
return observableOf(authMethods);
}
/**
* Create a new user
* @returns {User}
*/
public create(user: EPerson): Observable<EPerson> {
// Normally you would do an HTTP request to POST the user
// details and then return the new user object
// but, let's just return the new user for this example.
// this._authenticated = true;
return observableOf(user);
}
/**
* End session
* @returns {Observable<boolean>}

View File

@@ -12,6 +12,7 @@ import { DSpaceObject } from '../shared/dspace-object.model';
import { MetadataSchema } from '../metadata/metadata-schema.model';
import { MetadataField } from '../metadata/metadata-field.model';
import { ContentSource } from '../shared/content-source.model';
import { Registration } from '../shared/registration.model';
/* tslint:disable:max-classes-per-file */
export class RestResponse {
@@ -257,4 +258,17 @@ export class ContentSourceSuccessResponse extends RestResponse {
super(true, statusCode, statusText);
}
}
/**
* A successful response containing a Registration
*/
export class RegistrationSuccessResponse extends RestResponse {
constructor(
public registration: Registration,
public statusCode: number,
public statusText: string,
) {
super(true, statusCode, statusText);
}
}
/* tslint:enable:max-classes-per-file */

View File

@@ -140,6 +140,7 @@ import { Version } from './shared/version.model';
import { VersionHistory } from './shared/version-history.model';
import { WorkflowActionDataService } from './data/workflow-action-data.service';
import { WorkflowAction } from './tasks/models/workflow-action-object.model';
import { Registration } from './shared/registration.model';
import { MetadataSchemaDataService } from './data/metadata-schema-data.service';
import { MetadataFieldDataService } from './data/metadata-field-data.service';
@@ -308,7 +309,8 @@ export const models =
ExternalSourceEntry,
Version,
VersionHistory,
WorkflowAction
WorkflowAction,
Registration
];
@NgModule({

View File

@@ -0,0 +1,84 @@
import { RequestService } from './request.service';
import { EpersonRegistrationService } from './eperson-registration.service';
import { RegistrationSuccessResponse, RestResponse } from '../cache/response.models';
import { RequestEntry } from './request.reducer';
import { cold } from 'jasmine-marbles';
import { PostRequest } from './request.models';
import { Registration } from '../shared/registration.model';
import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub';
describe('EpersonRegistrationService', () => {
let service: EpersonRegistrationService;
let requestService: RequestService;
let halService: any;
const registration = new Registration();
registration.email = 'test@mail.org';
beforeEach(() => {
halService = new HALEndpointServiceStub('rest-url');
requestService = jasmine.createSpyObj('requestService', {
generateRequestId: 'request-id',
configure: {},
getByUUID: cold('a',
{a: Object.assign(new RequestEntry(), {response: new RestResponse(true, 200, 'Success')})})
});
service = new EpersonRegistrationService(
requestService,
halService
);
});
describe('getRegistrationEndpoint', () => {
it('should retrieve the registration endpoint', () => {
const expected = service.getRegistrationEndpoint();
expected.subscribe(((value) => {
expect(value).toEqual('rest-url/registrations');
}));
});
});
describe('getTokenSearchEndpoint', () => {
it('should return the token search endpoint for a specified token', () => {
const expected = service.getTokenSearchEndpoint('test-token');
expected.subscribe(((value) => {
expect(value).toEqual('rest-url/registrations/search/findByToken?token=test-token');
}));
});
});
describe('registerEmail', () => {
it('should send an email registration', () => {
const expected = service.registerEmail('test@mail.org');
expect(requestService.configure).toHaveBeenCalledWith(new PostRequest('request-id', 'rest-url/registrations', registration));
expect(expected).toBeObservable(cold('a', {a: new RestResponse(true, 200, 'Success')}));
});
});
describe('searchByToken', () => {
beforeEach(() => {
(requestService.getByUUID as jasmine.Spy).and.returnValue(
cold('a',
{a: Object.assign(new RequestEntry(), {response: new RegistrationSuccessResponse(registration, 200, 'Success')})})
);
});
it('should return a registration corresponding to the provided token', () => {
const expected = service.searchByToken('test-token');
expect(expected).toBeObservable(cold('(a|)', {
a: Object.assign(new Registration(), {
email: registration.email,
token: 'test-token'
})
}));
});
});
});

View File

@@ -0,0 +1,108 @@
import { Injectable } from '@angular/core';
import { RequestService } from './request.service';
import { HALEndpointService } from '../shared/hal-endpoint.service';
import { GetRequest, PostRequest } from './request.models';
import { Observable } from 'rxjs';
import { filter, find, map, take } from 'rxjs/operators';
import { hasValue, isNotEmpty } from '../../shared/empty.util';
import { Registration } from '../shared/registration.model';
import { filterSuccessfulResponses, getResponseFromEntry } from '../shared/operators';
import { ResponseParsingService } from './parsing.service';
import { GenericConstructor } from '../shared/generic-constructor';
import { RegistrationResponseParsingService } from './registration-response-parsing.service';
import { RegistrationSuccessResponse } from '../cache/response.models';
@Injectable(
{
providedIn: 'root',
}
)
/**
* Service that will register a new email address and request a token
*/
export class EpersonRegistrationService {
protected linkPath = 'registrations';
protected searchByTokenPath = '/search/findByToken?token=';
constructor(
protected requestService: RequestService,
protected halService: HALEndpointService,
) {
}
/**
* Retrieves the Registration endpoint
*/
getRegistrationEndpoint(): Observable<string> {
return this.halService.getEndpoint(this.linkPath);
}
/**
* Retrieves the endpoint to search by registration token
*/
getTokenSearchEndpoint(token: string): Observable<string> {
return this.halService.getEndpoint(this.linkPath).pipe(
filter((href: string) => isNotEmpty(href)),
map((href: string) => `${href}${this.searchByTokenPath}${token}`));
}
/**
* Register a new email address
* @param email
*/
registerEmail(email: string) {
const registration = new Registration();
registration.email = email;
const requestId = this.requestService.generateRequestId();
const hrefObs = this.getRegistrationEndpoint();
hrefObs.pipe(
find((href: string) => hasValue(href)),
map((href: string) => {
const request = new PostRequest(requestId, href, registration);
this.requestService.configure(request);
})
).subscribe();
return this.requestService.getByUUID(requestId).pipe(
getResponseFromEntry()
);
}
/**
* Search a registration based on the provided token
* @param token
*/
searchByToken(token: string): Observable<Registration> {
const requestId = this.requestService.generateRequestId();
const hrefObs = this.getTokenSearchEndpoint(token);
hrefObs.pipe(
find((href: string) => hasValue(href)),
map((href: string) => {
const request = new GetRequest(requestId, href);
Object.assign(request, {
getResponseParser(): GenericConstructor<ResponseParsingService> {
return RegistrationResponseParsingService;
}
});
this.requestService.configure(request);
})
).subscribe();
return this.requestService.getByUUID(requestId).pipe(
filterSuccessfulResponses(),
map((restResponse: RegistrationSuccessResponse) => {
return Object.assign(new Registration(), {email: restResponse.registration.email, token: token});
}),
take(1),
);
}
}

View File

@@ -0,0 +1,22 @@
import { Registration } from '../shared/registration.model';
import { RegistrationResponseParsingService } from './registration-response-parsing.service';
import { RegistrationSuccessResponse } from '../cache/response.models';
describe('RegistrationResponseParsingService', () => {
describe('parse', () => {
const registration = Object.assign(new Registration(), {email: 'test@email.org', token: 'test-token'});
const registrationResponseParsingService = new RegistrationResponseParsingService();
const data = {
payload: {email: 'test@email.org', token: 'test-token'},
statusCode: 200,
statusText: 'Success'
};
it('should parse a registration response', () => {
const expected = registrationResponseParsingService.parse({} as any, data);
expect(expected).toEqual(new RegistrationSuccessResponse(registration, 200, 'Success'));
});
});
});

View File

@@ -0,0 +1,24 @@
import { Injectable } from '@angular/core';
import { RegistrationSuccessResponse, RestResponse } from '../cache/response.models';
import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model';
import { ResponseParsingService } from './parsing.service';
import { RestRequest } from './request.models';
import { Registration } from '../shared/registration.model';
@Injectable({
providedIn: 'root',
})
/**
* Parsing service responsible for parsing a Registration response
*/
export class RegistrationResponseParsingService implements ResponseParsingService {
parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse {
const payload = data.payload;
const registration = Object.assign(new Registration(), payload);
return new RegistrationSuccessResponse(registration, data.statusCode, data.statusText);
}
}

View File

@@ -14,7 +14,7 @@ import { CoreState } from '../core.reducers';
import { ChangeAnalyzer } from '../data/change-analyzer';
import { PaginatedList } from '../data/paginated-list';
import { RemoteData } from '../data/remote-data';
import { DeleteByIDRequest, FindListOptions, PatchRequest } from '../data/request.models';
import { DeleteByIDRequest, FindListOptions, PatchRequest, PostRequest } from '../data/request.models';
import { RequestEntry } from '../data/request.reducer';
import { RequestService } from '../data/request.service';
import { HALEndpointService } from '../shared/hal-endpoint.service';
@@ -291,6 +291,15 @@ describe('EPersonDataService', () => {
});
});
describe('createEPersonForToken', () => {
it('should sent a postRquest with an eperson to the token endpoint', () => {
service.createEPersonForToken(EPersonMock, 'test-token');
const expected = new PostRequest(requestService.generateRequestId(), epersonsEndpoint + '?token=test-token', EPersonMock);
expect(requestService.configure).toHaveBeenCalledWith(expected);
});
});
});
function getRemotedataObservable(obj: any): Observable<RemoteData<any>> {

View File

@@ -3,7 +3,7 @@ import { Injectable } from '@angular/core';
import { createSelector, select, Store } from '@ngrx/store';
import { Operation } from 'fast-json-patch/lib/core';
import { Observable } from 'rxjs';
import { filter, map, take } from 'rxjs/operators';
import { filter, find, map, take } from 'rxjs/operators';
import {
EPeopleRegistryCancelEPersonAction,
EPeopleRegistryEditEPersonAction
@@ -22,7 +22,7 @@ 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 { FindListOptions, FindListRequest, PatchRequest, PostRequest, } from '../data/request.models';
import { RequestService } from '../data/request.service';
import { HALEndpointService } from '../shared/hal-endpoint.service';
import { getRemoteDataPayload, getSucceededRemoteData } from '../shared/operators';
@@ -165,17 +165,17 @@ export class EPersonDataService extends DataService<EPerson> {
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
}]
}];
}
return operations;
}
@@ -200,7 +200,7 @@ export class EPersonDataService extends DataService<EPerson> {
* Method to retrieve the eperson that is currently being edited
*/
public getActiveEPerson(): Observable<EPerson> {
return this.store.pipe(select(editEPersonSelector))
return this.store.pipe(select(editEPersonSelector));
}
/**
@@ -249,4 +249,25 @@ export class EPersonDataService extends DataService<EPerson> {
return '/admin/access-control/epeople';
}
/**
* Create a new EPerson using a token
* @param eperson
* @param token
*/
public createEPersonForToken(eperson: EPerson, token: string) {
const requestId = this.requestService.generateRequestId();
const hrefObs = this.getBrowseEndpoint().pipe(
map((href: string) => `${href}?token=${token}`));
hrefObs.pipe(
find((href: string) => hasValue(href)),
map((href: string) => {
const request = new PostRequest(requestId, href, eperson);
this.requestService.configure(request);
})
).subscribe();
return this.fetchResponse(requestId);
}
}

View File

@@ -57,6 +57,12 @@ export class EPerson extends DSpaceObject {
@autoserialize
public selfRegistered: boolean;
/**
* The password of this EPerson
*/
@autoserialize
public password: string;
/**
* Getter to retrieve the EPerson's full name as a string
*/

View File

@@ -0,0 +1,26 @@
/**
* Model representing a registration
*/
export class Registration {
/**
* The object type
*/
type: string;
/**
* The email linked to the registration
*/
email: string;
/**
* The user linked to the registration
*/
user: string;
/**
* The token linked to the registration
*/
token: string;
}

View File

@@ -0,0 +1,32 @@
import { FormBuilder, FormControl } from '@angular/forms';
import { async, fakeAsync } from '@angular/core/testing';
import { ConfirmedValidator } from './confirmed.validator';
describe('ConfirmedValidator', () => {
let passwordForm;
beforeEach(async(() => {
passwordForm = (new FormBuilder()).group({
password: new FormControl('', {}),
confirmPassword: new FormControl('', {})
}, {
validator: ConfirmedValidator('password', 'confirmPassword')
});
}));
it('should validate that the password and confirm password match', fakeAsync(() => {
passwordForm.get('password').patchValue('test-password');
passwordForm.get('confirmPassword').patchValue('test-password-mismatch');
expect(passwordForm.valid).toBe(false);
}));
it('should invalidate that the password and confirm password match', fakeAsync(() => {
passwordForm.get('password').patchValue('test-password');
passwordForm.get('confirmPassword').patchValue('test-password');
expect(passwordForm.valid).toBe(true);
}));
});

View File

@@ -0,0 +1,19 @@
import { FormGroup } from '@angular/forms';
/**
* Validator used to confirm that the password and confirmed password value are the same
*/
export function ConfirmedValidator(controlName: string, matchingControlName: string) {
return (formGroup: FormGroup) => {
const control = formGroup.controls[controlName];
const matchingControl = formGroup.controls[matchingControlName];
if (matchingControl.errors && !matchingControl.errors.confirmedValidator) {
return;
}
if (control.value !== matchingControl.value) {
matchingControl.setErrors({confirmedValidator: true});
} else {
matchingControl.setErrors(null);
}
};
}

View File

@@ -0,0 +1,101 @@
<div class="container">
<h2>{{'register-page.create-profile.header' | translate}}</h2>
<h4>{{'register-page.create-profile.identification.header' | translate}}</h4>
<div class="row">
<div class="col-12">
<label class="font-weight-bold"
for="email">{{'register-page.create-profile.identification.email' | translate}}</label>
<span id="email">{{(registration$ |async).email}}</span></div>
</div>
<form [class]="'ng-invalid'" [formGroup]="userInfoForm" (ngSubmit)="submitEperson()">
<div class="form-group">
<div class="row">
<div class="col-12">
<label class="font-weight-bold"
for="firstName">{{'register-page.create-profile.identification.first-name' | translate}}</label>
<input [className]="(firstName.invalid) && (firstName.dirty || firstName.touched) ? 'form-control is-invalid' :'form-control'"
type="text" id="firstName" formControlName="firstName"/>
<div *ngIf="firstName.invalid && (firstName.dirty || firstName.touched)"
class="invalid-feedback show-feedback">
<span *ngIf="firstName.errors && firstName.errors.required">
{{ 'register-page.create-profile.identification.first-name.error' | translate }}
</span>
</div>
</div>
</div>
<div class="row">
<div class="col-12">
<label class="font-weight-bold"
for="lastName">{{'register-page.create-profile.identification.last-name' | translate}}</label>
<input
[className]="(lastName.invalid) && (lastName.dirty || lastName.touched) ? 'form-control is-invalid' :'form-control'"
id="lastName" formControlName="lastName">
<div *ngIf="lastName.invalid && (lastName.dirty || lastName.touched)"
class="invalid-feedback show-feedback">
<span *ngIf="lastName.errors && lastName.errors.required">
{{ 'register-page.create-profile.identification.last-name.error' | translate }}
</span>
</div>
</div>
</div>
<div class="row">
<div class="col-12">
<label class="font-weight-bold"
for="contactPhone">{{'register-page.create-profile.identification.contact' | translate}}</label>
<input class="form-control" id="contactPhone" formControlName="contactPhone">
</div>
</div>
<div class="row">
<div class="col-12">
<label class="font-weight-bold"
for="language">{{'register-page.create-profile.identification.language' |translate}}</label>
<select id="language" formControlName="language" class="form-control">
<option [value]="''"></option>
<option *ngFor="let lang of activeLangs" [value]="lang.code">{{lang.label}}</option>
</select>
</div>
</div>
</div>
</form>
<h4>{{'register-page.create-profile.security.header' | translate}}</h4>
<p>{{'register-page.create-profile.security.info' | translate}}</p>
<form class="form-horizontal" [formGroup]="passwordForm" (ngSubmit)="submitEperson()">
<div class="form-group">
<div class="row">
<div class="col-12">
<label class="font-weight-bold" for="password">{{'register-page.create-profile.security.password' |translate}}</label>
<input [type]="'password'"
[className]="(!(isValidPassWord$|async)) && (password.dirty || password.touched) && (confirmPassword.dirty || confirmPassword.touched) ? 'form-control is-invalid' :'form-control'"
type="text" id="password" formControlName="password"/>
</div>
</div>
<div class="row">
<div class="col-12">
<label class="font-weight-bold" for="confirmPassword">{{'register-page.create-profile.security.confirm-password' |translate}}</label>
<input [type]="'password'"
[className]="(!(isValidPassWord$|async)) && (password.dirty || password.touched) && (confirmPassword.dirty || confirmPassword.touched) ? 'form-control is-invalid' :'form-control'"
id="confirmPassword" formControlName="confirmPassword">
<div *ngIf="!(isValidPassWord$|async) && (password.dirty || password.touched) && (confirmPassword.dirty || confirmPassword.touched)"
class="invalid-feedback show-feedback">
{{ 'register-page.create-profile.security.password.error' | translate }}
</div>
</div>
</div>
</div>
</form>
<div class="row">
<div class="col-12">
<button
[disabled]=" !(isValidPassWord$|async) || userInfoForm.invalid"
class="btn btn-default btn-primary" (click)="submitEperson()">{{'register-page.create-profile.submit' | translate}}</button>
</div>
</div>
</div>

View File

@@ -0,0 +1,157 @@
import { CreateProfileComponent } from './create-profile.component';
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { Registration } from '../../core/shared/registration.model';
import { CommonModule } from '@angular/common';
import { RouterTestingModule } from '@angular/router/testing';
import { TranslateModule } from '@ngx-translate/core';
import { ActivatedRoute, Router } from '@angular/router';
import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import { Store } from '@ngrx/store';
import { FormBuilder, ReactiveFormsModule } from '@angular/forms';
import { EPersonDataService } from '../../core/eperson/eperson-data.service';
import { NotificationsService } from '../../shared/notifications/notifications.service';
import { of as observableOf } from 'rxjs';
import { RestResponse } from '../../core/cache/response.models';
import { By } from '@angular/platform-browser';
import { CoreState } from '../../core/core.reducers';
import { EPerson } from '../../core/eperson/models/eperson.model';
import { AuthenticateAction } from '../../core/auth/auth.actions';
import { RouterStub } from '../../shared/testing/router.stub';
import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub';
describe('CreateProfileComponent', () => {
let comp: CreateProfileComponent;
let fixture: ComponentFixture<CreateProfileComponent>;
let router;
let route;
let ePersonDataService: EPersonDataService;
let notificationsService;
let store: Store<CoreState>;
const registration = Object.assign(new Registration(), {email: 'test@email.org', token: 'test-token'});
const values = {
metadata: {
'eperson.firstname': [
{
value: 'First'
}
],
'eperson.lastname': [
{
value: 'Last'
},
],
'eperson.phone': [
{
value: 'Phone'
}
],
'eperson.language': [
{
value: 'en'
}
]
},
email: 'test@email.org',
password: 'password',
canLogIn: true,
requireCertificate: false
};
const eperson = Object.assign(new EPerson(), values);
beforeEach(async(() => {
route = {data: observableOf({registration: registration})};
router = new RouterStub();
notificationsService = new NotificationsServiceStub();
ePersonDataService = jasmine.createSpyObj('ePersonDataService', {
createEPersonForToken: observableOf(new RestResponse(true, 200, 'Success'))
});
store = jasmine.createSpyObj('store', {
dispatch: {},
});
TestBed.configureTestingModule({
imports: [CommonModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), ReactiveFormsModule],
declarations: [CreateProfileComponent],
providers: [
{provide: Router, useValue: router},
{provide: ActivatedRoute, useValue: route},
{provide: Store, useValue: store},
{provide: EPersonDataService, useValue: ePersonDataService},
{provide: FormBuilder, useValue: new FormBuilder()},
{provide: NotificationsService, useValue: notificationsService},
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(CreateProfileComponent);
comp = fixture.componentInstance;
fixture.detectChanges();
});
describe('init', () => {
it('should initialise mail address', () => {
const elem = fixture.debugElement.queryAll(By.css('span#email'))[0].nativeElement;
expect(elem.innerHTML).toContain('test@email.org');
});
it('should initialise the form', () => {
const firstName = fixture.debugElement.queryAll(By.css('input#firstName'))[0].nativeElement;
const lastName = fixture.debugElement.queryAll(By.css('input#lastName'))[0].nativeElement;
const contactPhone = fixture.debugElement.queryAll(By.css('input#contactPhone'))[0].nativeElement;
const language = fixture.debugElement.queryAll(By.css('select#language'))[0].nativeElement;
const password = fixture.debugElement.queryAll(By.css('input#password'))[0].nativeElement;
const confirmPassword = fixture.debugElement.queryAll(By.css('input#confirmPassword'))[0].nativeElement;
expect(firstName).toBeDefined();
expect(lastName).toBeDefined();
expect(contactPhone).toBeDefined();
expect(language).toBeDefined();
expect(password).toBeDefined();
expect(confirmPassword).toBeDefined();
});
});
describe('submitEperson', () => {
it('should submit an eperson for creation and log in on success', () => {
comp.firstName.patchValue('First');
comp.lastName.patchValue('Last');
comp.contactPhone.patchValue('Phone');
comp.language.patchValue('en');
comp.password.patchValue('password');
comp.confirmPassword.patchValue('password');
comp.submitEperson();
expect(ePersonDataService.createEPersonForToken).toHaveBeenCalledWith(eperson, 'test-token');
expect(store.dispatch).toHaveBeenCalledWith(new AuthenticateAction('test@email.org', 'password'));
expect(router.navigate).toHaveBeenCalledWith(['/home']);
expect(notificationsService.success).toHaveBeenCalled();
});
it('should submit an eperson for creation and stay on page on error', () => {
(ePersonDataService.createEPersonForToken as jasmine.Spy).and.returnValue(observableOf(new RestResponse(false, 500, 'Error')));
comp.firstName.patchValue('First');
comp.lastName.patchValue('Last');
comp.contactPhone.patchValue('Phone');
comp.language.patchValue('en');
comp.password.patchValue('password');
comp.confirmPassword.patchValue('password');
comp.submitEperson();
expect(ePersonDataService.createEPersonForToken).toHaveBeenCalledWith(eperson, 'test-token');
expect(store.dispatch).not.toHaveBeenCalled();
expect(router.navigate).not.toHaveBeenCalled();
expect(notificationsService.error).toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,169 @@
import { Component, OnInit } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { debounceTime, map } from 'rxjs/operators';
import { Registration } from '../../core/shared/registration.model';
import { Observable } from 'rxjs';
import { FormBuilder, FormControl, FormGroup, Validators } from '@angular/forms';
import { ConfirmedValidator } from './confirmed.validator';
import { TranslateService } from '@ngx-translate/core';
import { EPersonDataService } from '../../core/eperson/eperson-data.service';
import { EPerson } from '../../core/eperson/models/eperson.model';
import { LangConfig } from '../../../config/lang-config.interface';
import { Store } from '@ngrx/store';
import { CoreState } from '../../core/core.reducers';
import { AuthenticateAction } from '../../core/auth/auth.actions';
import { NotificationsService } from '../../shared/notifications/notifications.service';
import { environment } from '../../../environments/environment';
/**
* Component that renders the create profile page to be used by a user registering through a token
*/
@Component({
selector: 'ds-create-profile',
templateUrl: './create-profile.component.html'
})
export class CreateProfileComponent implements OnInit {
registration$: Observable<Registration>;
email: string;
token: string;
userInfoForm: FormGroup;
passwordForm: FormGroup;
activeLangs: LangConfig[];
isValidPassWord$: Observable<boolean>;
constructor(
private translateService: TranslateService,
private ePersonDataService: EPersonDataService,
private store: Store<CoreState>,
private router: Router,
private route: ActivatedRoute,
private formBuilder: FormBuilder,
private notificationsService: NotificationsService
) {
}
ngOnInit(): void {
this.registration$ = this.route.data.pipe(
map((data) => data.registration as Registration),
);
this.registration$.subscribe((registration: Registration) => {
this.email = registration.email;
this.token = registration.token;
});
this.activeLangs = environment.languages.filter((MyLangConfig) => MyLangConfig.active === true);
this.userInfoForm = this.formBuilder.group({
firstName: new FormControl('', {
validators: [Validators.required],
}),
lastName: new FormControl('', {
validators: [Validators.required],
}),
contactPhone: new FormControl(''),
language: new FormControl(''),
});
this.passwordForm = this.formBuilder.group({
password: new FormControl('', {
validators: [Validators.required, Validators.minLength(6)],
updateOn: 'change'
}),
confirmPassword: new FormControl('', {
validators: [Validators.required],
updateOn: 'change'
})
}, {
validator: ConfirmedValidator('password', 'confirmPassword')
});
this.isValidPassWord$ = this.passwordForm.statusChanges.pipe(
debounceTime(300),
map((status: string) => {
if (status === 'VALID') {
return true;
} else {
return false;
}
})
);
}
get firstName() {
return this.userInfoForm.get('firstName');
}
get lastName() {
return this.userInfoForm.get('lastName');
}
get contactPhone() {
return this.userInfoForm.get('contactPhone');
}
get language() {
return this.userInfoForm.get('language');
}
get password() {
return this.passwordForm.get('password');
}
get confirmPassword() {
return this.passwordForm.get('confirmPassword');
}
/**
* Submits the eperson to the service to be created.
* The submission will not be made when the form is not valid.
*/
submitEperson() {
if (!(this.userInfoForm.invalid || this.passwordForm.invalid)) {
const values = {
metadata: {
'eperson.firstname': [
{
value: this.firstName.value
}
],
'eperson.lastname': [
{
value: this.lastName.value
},
],
'eperson.phone': [
{
value: this.contactPhone.value
}
],
'eperson.language': [
{
value: this.language.value
}
]
},
email: this.email,
password: this.password.value,
canLogIn: true,
requireCertificate: false
};
const eperson = Object.assign(new EPerson(), values);
this.ePersonDataService.createEPersonForToken(eperson, this.token).subscribe((response) => {
if (response.isSuccessful) {
this.notificationsService.success(this.translateService.get('register-page.create-profile.submit.success.head'),
this.translateService.get('register-page.create-profile.submit.success.content'));
this.store.dispatch(new AuthenticateAction(this.email, this.password.value));
this.router.navigate(['/home']);
} else {
this.notificationsService.error(this.translateService.get('register-page.create-profile.submit.error.head'),
this.translateService.get('register-page.create-profile.submit.error.content'));
}
});
}
}
}

View File

@@ -0,0 +1,36 @@
<div class="container">
<h2>{{'register-page.registration.header'|translate}}</h2>
<p>{{'register-page.registration.info' | translate}}</p>
<form [class]="'ng-invalid'" [formGroup]="form" (ngSubmit)="register()">
<div class="form-group">
<div class="row">
<div class="col-12">
<label class="font-weight-bold"
for="email">{{'register-page.registration.email' | translate}}</label>
<input [className]="(email.invalid) && (email.dirty || email.touched) ? 'form-control is-invalid' :'form-control'"
type="text" id="email" formControlName="email"/>
<div *ngIf="email.invalid && (email.dirty || email.touched)"
class="invalid-feedback show-feedback">
<span *ngIf="email.errors && email.errors.required">
{{ 'register-page.registration.email.error.required' | translate }}
</span>
<span *ngIf="email.errors && email.errors.pattern">
{{ 'register-page.registration.email.error.pattern' | translate }}
</span>
</div>
</div>
<div class="col-12">
{{'register-page.registration.email.hint' |translate}}
</div>
</div>
</div>
</form>
<button class="btn btn-primary"
[disabled]="form.invalid"
(click)="register()">{{'register-page.registration.register'| translate}}</button>
</div>

View File

@@ -0,0 +1,92 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { of as observableOf } from 'rxjs';
import { RestResponse } from '../../core/cache/response.models';
import { CommonModule } from '@angular/common';
import { RouterTestingModule } from '@angular/router/testing';
import { TranslateModule } from '@ngx-translate/core';
import { FormBuilder, ReactiveFormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { NotificationsService } from '../../shared/notifications/notifications.service';
import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import { RegisterEmailComponent } from './register-email.component';
import { EpersonRegistrationService } from '../../core/data/eperson-registration.service';
import { By } from '@angular/platform-browser';
import { RouterStub } from '../../shared/testing/router.stub';
import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub';
describe('RegisterEmailComponent', () => {
let comp: RegisterEmailComponent;
let fixture: ComponentFixture<RegisterEmailComponent>;
let router;
let epersonRegistrationService: EpersonRegistrationService;
let notificationsService;
beforeEach(async(() => {
router = new RouterStub();
notificationsService = new NotificationsServiceStub();
epersonRegistrationService = jasmine.createSpyObj('epersonRegistrationService', {
registerEmail: observableOf(new RestResponse(true, 200, 'Success'))
});
TestBed.configureTestingModule({
imports: [CommonModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), ReactiveFormsModule],
declarations: [RegisterEmailComponent],
providers: [
{provide: Router, useValue: router},
{provide: EpersonRegistrationService, useValue: epersonRegistrationService},
{provide: FormBuilder, useValue: new FormBuilder()},
{provide: NotificationsService, useValue: notificationsService},
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(RegisterEmailComponent);
comp = fixture.componentInstance;
fixture.detectChanges();
});
describe('init', () => {
it('should initialise the form', () => {
const elem = fixture.debugElement.queryAll(By.css('input#email'))[0].nativeElement;
expect(elem).toBeDefined();
});
});
describe('email validation', () => {
it('should be invalid when no email is present', () => {
expect(comp.form.invalid).toBeTrue();
});
it('should be invalid when no valid email is present', () => {
comp.form.patchValue({email: 'invalid'});
expect(comp.form.invalid).toBeTrue();
});
it('should be invalid when no valid email is present', () => {
comp.form.patchValue({email: 'valid@email.org'});
expect(comp.form.invalid).toBeFalse();
});
});
describe('register', () => {
it('should send a registration to the service and on success display a message and return to home', () => {
comp.form.patchValue({email: 'valid@email.org'});
comp.register();
expect(epersonRegistrationService.registerEmail).toHaveBeenCalledWith('valid@email.org');
expect(notificationsService.success).toHaveBeenCalled();
expect(router.navigate).toHaveBeenCalledWith(['/home']);
});
it('should send a registration to the service and on error display a message', () => {
(epersonRegistrationService.registerEmail as jasmine.Spy).and.returnValue(observableOf(new RestResponse(false, 400, 'Bad Request')));
comp.form.patchValue({email: 'valid@email.org'});
comp.register();
expect(epersonRegistrationService.registerEmail).toHaveBeenCalledWith('valid@email.org');
expect(notificationsService.error).toHaveBeenCalled();
expect(router.navigate).not.toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,64 @@
import { Component, OnInit } from '@angular/core';
import { EpersonRegistrationService } from '../../core/data/eperson-registration.service';
import { RestResponse } from '../../core/cache/response.models';
import { NotificationsService } from '../../shared/notifications/notifications.service';
import { TranslateService } from '@ngx-translate/core';
import { Router } from '@angular/router';
import { FormBuilder, FormControl, FormGroup, Validators } from '@angular/forms';
@Component({
selector: 'ds-register-email',
templateUrl: './register-email.component.html'
})
/**
* Component responsible the email registration step
*/
export class RegisterEmailComponent implements OnInit {
form: FormGroup;
constructor(
private epersonRegistrationService: EpersonRegistrationService,
private notificationService: NotificationsService,
private translateService: TranslateService,
private router: Router,
private formBuilder: FormBuilder
) {
}
ngOnInit(): void {
this.form = this.formBuilder.group({
email: new FormControl('', {
validators: [Validators.required,
Validators.pattern('^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,4}$')
],
})
});
}
/**
* Register an email address
*/
register() {
if (!this.form.invalid) {
this.epersonRegistrationService.registerEmail(this.email.value).subscribe((response: RestResponse) => {
if (response.isSuccessful) {
this.notificationService.success(this.translateService.get('register-page.registration.success.head'),
this.translateService.get('register-page.registration.success.content', {email: this.email.value}));
this.router.navigate(['/home']);
} else {
this.notificationService.error(this.translateService.get('register-page.registration.error.head'),
this.translateService.get('register-page.registration.error.content', {email: this.email.value}));
}
}
);
}
}
get email() {
return this.form.get('email');
}
}

View File

@@ -0,0 +1,32 @@
import { NgModule } from '@angular/core';
import { RouterModule } from '@angular/router';
import { RegisterEmailComponent } from './register-email/register-email.component';
import { CreateProfileComponent } from './create-profile/create-profile.component';
import { RegistrationResolver } from './registration.resolver';
import { ItemPageResolver } from '../+item-page/item-page.resolver';
@NgModule({
imports: [
RouterModule.forChild([
{
path: '',
component: RegisterEmailComponent,
data: {title: 'register-email.title'},
},
{
path: ':token',
component: CreateProfileComponent,
resolve: {registration: RegistrationResolver}
}
])
],
providers: [
RegistrationResolver,
ItemPageResolver
]
})
/**
* This module defines the default component to load when navigating to the mydspace page path.
*/
export class RegisterPageRoutingModule {
}

View File

@@ -0,0 +1,24 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { SharedModule } from '../shared/shared.module';
import { RegisterPageRoutingModule } from './register-page-routing.module';
import { RegisterEmailComponent } from './register-email/register-email.component';
import { CreateProfileComponent } from './create-profile/create-profile.component';
@NgModule({
imports: [
CommonModule,
SharedModule,
RegisterPageRoutingModule,
],
declarations: [
RegisterEmailComponent,
CreateProfileComponent
],
providers: [],
entryComponents: []
})
export class RegisterPageModule {
}

View File

@@ -0,0 +1,32 @@
import { RegistrationResolver } from './registration.resolver';
import { EpersonRegistrationService } from '../core/data/eperson-registration.service';
import { of as observableOf } from 'rxjs';
import { Registration } from '../core/shared/registration.model';
import { first } from 'rxjs/operators';
describe('RegistrationResolver', () => {
let resolver: RegistrationResolver;
let epersonRegistrationService: EpersonRegistrationService;
const token = 'test-token';
const registration = Object.assign(new Registration(), {email: 'test@email.org', token: token});
beforeEach(() => {
epersonRegistrationService = jasmine.createSpyObj('epersonRegistrationService', {
searchByToken: observableOf(registration)
});
resolver = new RegistrationResolver(epersonRegistrationService);
});
describe('resolve', () => {
it('should resolve a registration based on the token', () => {
resolver.resolve({params: {token: token}} as any, undefined)
.pipe(first())
.subscribe(
(resolved) => {
expect(resolved.token).toEqual(token);
expect(resolved.email).toEqual('test@email.org');
}
);
});
});
});

View File

@@ -0,0 +1,20 @@
import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, Resolve, RouterStateSnapshot } from '@angular/router';
import { EpersonRegistrationService } from '../core/data/eperson-registration.service';
import { Registration } from '../core/shared/registration.model';
import { Observable } from 'rxjs';
@Injectable()
/**
* Resolver to resolve a Registration object based on the provided token
*/
export class RegistrationResolver implements Resolve<Registration> {
constructor(private epersonRegistrationService: EpersonRegistrationService) {
}
resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<Registration> {
const token = route.params.token;
return this.epersonRegistrationService.searchByToken(token);
}
}

View File

@@ -8,6 +8,6 @@
</ng-container>
<div class="dropdown-divider"></div>
<a class="dropdown-item" href="#">{{"login.form.new-user" | translate}}</a>
<a class="dropdown-item" [routerLink]="[getRegisterPath()]">{{"login.form.new-user" | translate}}</a>
<a class="dropdown-item" href="#">{{"login.form.forgot-password" | translate}}</a>
</div>

View File

@@ -1,7 +1,7 @@
import { Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import { async, ComponentFixture, inject, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { Store, StoreModule } from '@ngrx/store';
import { StoreModule } from '@ngrx/store';
import { LogInComponent } from './log-in.component';
import { authReducer } from '../../core/auth/auth.reducer';
@@ -13,11 +13,11 @@ import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { SharedModule } from '../shared.module';
import { NativeWindowMockFactory } from '../mocks/mock-native-window-ref';
import { ActivatedRouteStub } from '../testing/active-router.stub';
import { ActivatedRoute, Router } from '@angular/router';
import { RouterStub } from '../testing/router.stub';
import { ActivatedRoute } from '@angular/router';
import { NativeWindowService } from '../../core/services/window.service';
import { provideMockStore } from '@ngrx/store/testing';
import { createTestComponent } from '../testing/utils.test';
import { RouterTestingModule } from '@angular/router/testing';
describe('LogInComponent', () => {
@@ -46,6 +46,7 @@ describe('LogInComponent', () => {
strictActionImmutability: false
}
}),
RouterTestingModule,
SharedModule,
TranslateModule.forRoot()
],
@@ -55,7 +56,7 @@ describe('LogInComponent', () => {
providers: [
{ provide: AuthService, useClass: AuthServiceStub },
{ provide: NativeWindowService, useFactory: NativeWindowMockFactory },
{ provide: Router, useValue: new RouterStub() },
// { provide: Router, useValue: new RouterStub() },
{ provide: ActivatedRoute, useValue: new ActivatedRouteStub() },
provideMockStore({ initialState }),
LogInComponent

View File

@@ -8,6 +8,7 @@ import { AuthMethod } from '../../core/auth/models/auth.method';
import { getAuthenticationMethods, isAuthenticated, isAuthenticationLoading } from '../../core/auth/selectors';
import { CoreState } from '../../core/core.reducers';
import { AuthService } from '../../core/auth/auth.service';
import { getRegisterPath } from '../../app-routing.module';
/**
* /users/sign-in
@@ -82,4 +83,7 @@ export class LogInComponent implements OnInit, OnDestroy {
this.alive = false;
}
getRegisterPath() {
return getRegisterPath();
}
}

View File

@@ -2056,6 +2056,70 @@
"publication.search.title": "DSpace Angular :: Publication Search",
"register-email.title": "New user registration",
"register-page.create-profile.header": "Create Profile",
"register-page.create-profile.identification.header": "Identify",
"register-page.create-profile.identification.email": "Email Address",
"register-page.create-profile.identification.first-name": "First Name *",
"register-page.create-profile.identification.first-name.error": "Please fill in a First Name",
"register-page.create-profile.identification.last-name": "Last Name *",
"register-page.create-profile.identification.last-name.error": "Please fill in a Last Name",
"register-page.create-profile.identification.contact": "Contact Telephone",
"register-page.create-profile.identification.language": "Language",
"register-page.create-profile.security.header": "Security",
"register-page.create-profile.security.info": "Please enter a password in the box below, and confirm it by typing it again into the second box. It should be at least six characters long.",
"register-page.create-profile.security.password": "Password *",
"register-page.create-profile.security.confirm-password": "Retype to confirm *",
"register-page.create-profile.security.password.error": "Please enter a password in the box below, and confirm it by typing it again into the second box. It should be at least six characters long.",
"register-page.create-profile.submit": "Complete Registration",
"register-page.create-profile.submit.error.content": "Something went wrong while registering a new user.",
"register-page.create-profile.submit.error.head": "Registration failed",
"register-page.create-profile.submit.success.content": "The registration was successful. You have been logged in as the created user.",
"register-page.create-profile.submit.success.head": "Registration completed",
"register-page.registration.header": "New user registration",
"register-page.registration.info": "Register an account to subscribe to collections for email updates, and submit new items to DSpace.",
"register-page.registration.email": "Email Address *",
"register-page.registration.email.error.required": "Please fill in an email address",
"register-page.registration.email.error.pattern": "Please fill in a valid email address",
"register-page.registration.email.hint": "This address will be verified and used as your login name.",
"register-page.registration.register": "Register",
"register-page.registration.success.head": "Verification email sent",
"register-page.registration.success.content": "An email has been sent to {{ email }} containing a special URL and further instructions.",
"register-page.registration.error.head": "Error when trying to register email",
"register-page.registration.error.content": "An error occured when registering the following email address: {{ email }}",
"relationships.isAuthorOf": "Authors",