88082: Issue #1136 - Forgot/Register links loading fix

This commit is contained in:
Kristof De Langhe
2022-03-01 15:11:25 +01:00
parent 9fc7b57157
commit 7924db512b
15 changed files with 223 additions and 42 deletions

View File

@@ -90,10 +90,12 @@ describe('EpersonRegistrationService', () => {
const expected = service.searchByToken('test-token');
expect(expected).toBeObservable(cold('(a|)', {
a: Object.assign(new Registration(), {
email: registrationWithUser.email,
token: 'test-token',
user: registrationWithUser.user
a: jasmine.objectContaining({
payload: Object.assign(new Registration(), {
email: registrationWithUser.email,
token: 'test-token',
user: registrationWithUser.user
})
})
}));
});

View File

@@ -79,7 +79,7 @@ export class EpersonRegistrationService {
* Search a registration based on the provided token
* @param token
*/
searchByToken(token: string): Observable<Registration> {
searchByToken(token: string): Observable<RemoteData<Registration>> {
const requestId = this.requestService.generateRequestId();
const href$ = this.getTokenSearchEndpoint(token).pipe(
@@ -97,15 +97,14 @@ export class EpersonRegistrationService {
});
return this.rdbService.buildSingle<Registration>(href$).pipe(
skipWhile((rd: RemoteData<Registration>) => rd.isStale),
getFirstSucceededRemoteData(),
map((restResponse: RemoteData<Registration>) => {
return Object.assign(new Registration(), {
email: restResponse.payload.email, token: token, user: restResponse.payload.user
});
}),
map((rd) => {
if (rd.hasSucceeded && hasValue(rd.payload)) {
return Object.assign(rd, { payload: Object.assign(rd.payload, { token }) });
} else {
return rd;
}
})
);
}
}

View File

@@ -197,19 +197,27 @@ export const getAllSucceededRemoteListPayload = <T>() =>
*
* @param router The router used to navigate to a new page
* @param authService Service to check if the user is authenticated
* @param navigateAction Optional action to take with the Promise returned by navigateByUrl
*/
export const redirectOn4xx = <T>(router: Router, authService: AuthService) =>
// tslint:disable-next-line:no-empty
export const redirectOn4xx = <T>(router: Router, authService: AuthService, navigateAction?: (nav: boolean) => void) =>
(source: Observable<RemoteData<T>>): Observable<RemoteData<T>> =>
source.pipe(
withLatestFrom(authService.isAuthenticated()),
filter(([rd, isAuthenticated]: [RemoteData<T>, boolean]) => {
if (rd.hasFailed) {
if (rd.statusCode === 404 || rd.statusCode === 422) {
router.navigateByUrl(getPageNotFoundRoute(), { skipLocationChange: true });
const promise = router.navigateByUrl(getPageNotFoundRoute(), { skipLocationChange: true });
if (hasValue(navigateAction)) {
promise.then(navigateAction);
}
return false;
} else if (rd.statusCode === 403 || rd.statusCode === 401) {
if (isAuthenticated) {
router.navigateByUrl(getForbiddenRoute(), { skipLocationChange: true });
const promise = router.navigateByUrl(getForbiddenRoute(), { skipLocationChange: true });
if (hasValue(navigateAction)) {
promise.then(navigateAction);
}
return false;
} else {
authService.setRedirectUrl(router.url);

View File

@@ -1,4 +1,4 @@
<div class="container">
<div class="container" *ngIf="(registration$ |async)">
<h3 class="mb-4">{{'forgot-password.form.head' | translate}}</h3>
<div class="card mb-4">
<div class="card-header">{{'forgot-password.form.identification.header' | translate}}</div>
@@ -33,4 +33,4 @@
(click)="submit()">{{'forgot-password.form.submit' | translate}}</button>
</div>
</div>
</div>
</div>

View File

@@ -16,7 +16,11 @@ import { Registration } from '../../core/shared/registration.model';
import { ForgotPasswordFormComponent } from './forgot-password-form.component';
import { By } from '@angular/platform-browser';
import { AuthenticateAction } from '../../core/auth/auth.actions';
import { createFailedRemoteDataObject$, createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
import {
createFailedRemoteDataObject$,
createSuccessfulRemoteDataObject,
createSuccessfulRemoteDataObject$
} from '../../shared/remote-data.utils';
describe('ForgotPasswordFormComponent', () => {
let comp: ForgotPasswordFormComponent;
@@ -36,7 +40,7 @@ describe('ForgotPasswordFormComponent', () => {
beforeEach(waitForAsync(() => {
route = {data: observableOf({registration: registration})};
route = {data: observableOf({registration: createSuccessfulRemoteDataObject(registration)})};
router = new RouterStub();
notificationsService = new NotificationsServiceStub();

View File

@@ -11,7 +11,10 @@ import { Store } from '@ngrx/store';
import { CoreState } from '../../core/core.reducers';
import { RemoteData } from '../../core/data/remote-data';
import { EPerson } from '../../core/eperson/models/eperson.model';
import { getFirstCompletedRemoteData } from '../../core/shared/operators';
import {
getFirstCompletedRemoteData,
getFirstSucceededRemoteDataPayload,
} from '../../core/shared/operators';
@Component({
selector: 'ds-forgot-password-form',
@@ -48,7 +51,8 @@ export class ForgotPasswordFormComponent {
ngOnInit(): void {
this.registration$ = this.route.data.pipe(
map((data) => data.registration as Registration),
map((data) => data.registration as RemoteData<Registration>),
getFirstSucceededRemoteDataPayload(),
);
this.registration$.subscribe((registration: Registration) => {
this.email = registration.email;

View File

@@ -1,9 +1,9 @@
import { NgModule } from '@angular/core';
import { RouterModule } from '@angular/router';
import { ItemPageResolver } from '../item-page/item-page.resolver';
import { RegistrationResolver } from '../register-email-form/registration.resolver';
import { ThemedForgotPasswordFormComponent } from './forgot-password-form/themed-forgot-password-form.component';
import { ThemedForgotEmailComponent } from './forgot-password-email/themed-forgot-email.component';
import { RegistrationGuard } from '../register-page/registration.guard';
@NgModule({
imports: [
@@ -16,12 +16,11 @@ import { ThemedForgotEmailComponent } from './forgot-password-email/themed-forgo
{
path: ':token',
component: ThemedForgotPasswordFormComponent,
resolve: {registration: RegistrationResolver}
canActivate: [ RegistrationGuard ],
}
])
],
providers: [
RegistrationResolver,
ItemPageResolver,
]
})

View File

@@ -1,8 +1,8 @@
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';
import { createSuccessfulRemoteDataObject$ } from '../shared/remote-data.utils';
describe('RegistrationResolver', () => {
let resolver: RegistrationResolver;
@@ -13,7 +13,7 @@ describe('RegistrationResolver', () => {
beforeEach(() => {
epersonRegistrationService = jasmine.createSpyObj('epersonRegistrationService', {
searchByToken: observableOf(registration)
searchByToken: createSuccessfulRemoteDataObject$(registration)
});
resolver = new RegistrationResolver(epersonRegistrationService);
});
@@ -23,9 +23,9 @@ describe('RegistrationResolver', () => {
.pipe(first())
.subscribe(
(resolved) => {
expect(resolved.token).toEqual(token);
expect(resolved.email).toEqual('test@email.org');
expect(resolved.user).toEqual('user-uuid');
expect(resolved.payload.token).toEqual(token);
expect(resolved.payload.email).toEqual('test@email.org');
expect(resolved.payload.user).toEqual('user-uuid');
done();
}
);

View File

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

View File

@@ -1,4 +1,4 @@
<div class="container">
<div class="container" *ngIf="(registration$ |async)">
<h3 class="mb-4">{{'register-page.create-profile.header' | translate}}</h3>
<div class="card mb-4">
<div class="card-header">{{'register-page.create-profile.identification.header' | translate}}</div>

View File

@@ -21,7 +21,11 @@ import {
END_USER_AGREEMENT_METADATA_FIELD,
EndUserAgreementService
} from '../../core/end-user-agreement/end-user-agreement.service';
import { createFailedRemoteDataObject$, createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
import {
createFailedRemoteDataObject$,
createSuccessfulRemoteDataObject,
createSuccessfulRemoteDataObject$
} from '../../shared/remote-data.utils';
describe('CreateProfileComponent', () => {
let comp: CreateProfileComponent;
@@ -106,7 +110,7 @@ describe('CreateProfileComponent', () => {
};
epersonWithAgreement = Object.assign(new EPerson(), valuesWithAgreement);
route = {data: observableOf({registration: registration})};
route = {data: observableOf({registration: createSuccessfulRemoteDataObject(registration)})};
router = new RouterStub();
notificationsService = new NotificationsServiceStub();

View File

@@ -19,7 +19,7 @@ import {
END_USER_AGREEMENT_METADATA_FIELD,
EndUserAgreementService
} from '../../core/end-user-agreement/end-user-agreement.service';
import { getFirstCompletedRemoteData } from '../../core/shared/operators';
import { getFirstCompletedRemoteData, getFirstSucceededRemoteDataPayload } from '../../core/shared/operators';
/**
* Component that renders the create profile page to be used by a user registering through a token
@@ -56,7 +56,8 @@ export class CreateProfileComponent implements OnInit {
ngOnInit(): void {
this.registration$ = this.route.data.pipe(
map((data) => data.registration as Registration),
map((data) => data.registration as RemoteData<Registration>),
getFirstSucceededRemoteDataPayload(),
);
this.registration$.subscribe((registration: Registration) => {
this.email = registration.email;

View File

@@ -2,9 +2,9 @@ import { NgModule } from '@angular/core';
import { RouterModule } from '@angular/router';
import { RegisterEmailComponent } from './register-email/register-email.component';
import { ItemPageResolver } from '../item-page/item-page.resolver';
import { RegistrationResolver } from '../register-email-form/registration.resolver';
import { EndUserAgreementCookieGuard } from '../core/end-user-agreement/end-user-agreement-cookie.guard';
import { ThemedCreateProfileComponent } from './create-profile/themed-create-profile.component';
import { RegistrationGuard } from './registration.guard';
@NgModule({
imports: [
@@ -17,13 +17,14 @@ import { ThemedCreateProfileComponent } from './create-profile/themed-create-pro
{
path: ':token',
component: ThemedCreateProfileComponent,
resolve: {registration: RegistrationResolver},
canActivate: [EndUserAgreementCookieGuard]
canActivate: [
RegistrationGuard,
EndUserAgreementCookieGuard,
],
}
])
],
providers: [
RegistrationResolver,
ItemPageResolver
]
})

View File

@@ -0,0 +1,108 @@
import { RegistrationGuard } from './registration.guard';
import { EpersonRegistrationService } from '../core/data/eperson-registration.service';
import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router';
import { AuthService } from '../core/auth/auth.service';
import { Location } from '@angular/common';
import {
createFailedRemoteDataObject$,
createSuccessfulRemoteDataObject,
} from '../shared/remote-data.utils';
import { Registration } from '../core/shared/registration.model';
import { of as observableOf } from 'rxjs/internal/observable/of';
import { RemoteData } from '../core/data/remote-data';
describe('RegistrationGuard', () => {
let guard: RegistrationGuard;
let epersonRegistrationService: EpersonRegistrationService;
let router: Router;
let authService: AuthService;
let location: Location;
let registration: Registration;
let registrationRD: RemoteData<Registration>;
let currentUrl: string;
let startingRouteData: any;
let route: ActivatedRouteSnapshot;
let state: RouterStateSnapshot;
beforeEach(() => {
registration = Object.assign(new Registration(), {
email: 'test@email.com',
token: 'testToken',
user: 'testUser',
});
registrationRD = createSuccessfulRemoteDataObject(registration);
currentUrl = 'test-current-url';
startingRouteData = {
existingData: 'some-existing-data',
};
route = Object.assign(new ActivatedRouteSnapshot(), {
data: Object.assign({}, startingRouteData),
params: {
token: 'testToken',
},
});
state = Object.assign({
url: currentUrl,
});
epersonRegistrationService = jasmine.createSpyObj('epersonRegistrationService', {
searchByToken: observableOf(registrationRD),
});
router = jasmine.createSpyObj('router', {
navigateByUrl: Promise.resolve(),
}, {
url: currentUrl,
});
authService = jasmine.createSpyObj('authService', {
isAuthenticated: observableOf(false),
setRedirectUrl: {},
});
location = jasmine.createSpyObj('location', ['replaceState']);
guard = new RegistrationGuard(epersonRegistrationService, router, authService, location);
});
describe('canActivate', () => {
describe('when searchByToken returns a successful response', () => {
beforeEach(() => {
(epersonRegistrationService.searchByToken as jasmine.Spy).and.returnValue(observableOf(registrationRD));
});
it('should return true', (done) => {
guard.canActivate(route, state).subscribe((result) => {
expect(result).toEqual(true);
done();
});
});
it('should add the response to the route\'s data', (done) => {
guard.canActivate(route, state).subscribe(() => {
expect(route.data).toEqual({ ...startingRouteData, registration: registrationRD });
done();
});
});
it('should not redirect', (done) => {
guard.canActivate(route, state).subscribe(() => {
expect(router.navigateByUrl).not.toHaveBeenCalled();
done();
});
});
});
describe('when searchByToken returns a 404 response', () => {
beforeEach(() => {
(epersonRegistrationService.searchByToken as jasmine.Spy).and.returnValue(createFailedRemoteDataObject$('Not Found', 404));
});
it('should redirect', () => {
guard.canActivate(route, state).subscribe();
expect(router.navigateByUrl).toHaveBeenCalled();
});
});
});
});

View File

@@ -0,0 +1,47 @@
import { ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot } from '@angular/router';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs/internal/Observable';
import { EpersonRegistrationService } from '../core/data/eperson-registration.service';
import { AuthService } from '../core/auth/auth.service';
import { map } from 'rxjs/operators';
import { getFirstCompletedRemoteData, redirectOn4xx } from '../core/shared/operators';
import { Location } from '@angular/common';
@Injectable({
providedIn: 'root'
})
/**
* A guard responsible for redirecting to 4xx pages upon retrieving a Registration object
* The guard also adds the resulting RemoteData<Registration> object to the route's data for further usage in components
* The reason this is a guard and not a resolver, is because it has to run before the EndUserAgreementCookieGuard
*/
export class RegistrationGuard implements CanActivate {
constructor(private epersonRegistrationService: EpersonRegistrationService,
private router: Router,
private authService: AuthService,
private location: Location) {
}
/**
* Can the user activate the route? Returns true if the provided token resolves to an existing Registration, false if
* not. Redirects to 4xx page on 4xx error. Adds the resulting RemoteData<Registration> object to the route's
* data.registration property
* @param route
* @param state
*/
canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<boolean> {
const token = route.params.token;
return this.epersonRegistrationService.searchByToken(token).pipe(
getFirstCompletedRemoteData(),
// The replaceState call is an action taken by the Promise returned by navigateByUrl within redirectOn4xx
// It ensures the user stays on the correct URL when redirected to a 4xx page
// See this related issue's comment: https://github.com/angular/angular/issues/16981#issuecomment-549330207
redirectOn4xx(this.router, this.authService, () => this.location.replaceState(state.url)),
map((rd) => {
route.data = { ...route.data, registration: rd };
return rd.hasSucceeded;
}),
);
}
}