mirror of
https://github.com/DSpace/dspace-angular.git
synced 2025-10-07 01:54:15 +00:00
[CST-14902][CST-15073][CST-15074] Adds ORCID login flow with private email
This commit is contained in:

committed by
Vincenzo Mecca

parent
fca5700012
commit
214a77a65c
@@ -1,8 +1,4 @@
|
||||
import {
|
||||
InMemoryScrollingOptions,
|
||||
Route,
|
||||
RouterConfigOptions,
|
||||
} from '@angular/router';
|
||||
import { InMemoryScrollingOptions, Route, RouterConfigOptions, } from '@angular/router';
|
||||
|
||||
import { NOTIFICATIONS_MODULE_PATH } from './admin/admin-routing-paths';
|
||||
import {
|
||||
@@ -25,8 +21,12 @@ import { COLLECTION_MODULE_PATH } from './collection-page/collection-page-routin
|
||||
import { COMMUNITY_MODULE_PATH } from './community-page/community-page-routing-paths';
|
||||
import { authBlockingGuard } from './core/auth/auth-blocking.guard';
|
||||
import { authenticatedGuard } from './core/auth/authenticated.guard';
|
||||
import { groupAdministratorGuard } from './core/data/feature-authorization/feature-authorization-guard/group-administrator.guard';
|
||||
import { siteAdministratorGuard } from './core/data/feature-authorization/feature-authorization-guard/site-administrator.guard';
|
||||
import {
|
||||
groupAdministratorGuard
|
||||
} from './core/data/feature-authorization/feature-authorization-guard/group-administrator.guard';
|
||||
import {
|
||||
siteAdministratorGuard
|
||||
} from './core/data/feature-authorization/feature-authorization-guard/site-administrator.guard';
|
||||
import { siteRegisterGuard } from './core/data/feature-authorization/feature-authorization-guard/site-register.guard';
|
||||
import { endUserAgreementCurrentUserGuard } from './core/end-user-agreement/end-user-agreement-current-user.guard';
|
||||
import { reloadGuard } from './core/reload/reload.guard';
|
||||
@@ -37,7 +37,9 @@ import { ITEM_MODULE_PATH } from './item-page/item-page-routing-paths';
|
||||
import { menuResolver } from './menuResolver';
|
||||
import { provideSuggestionNotificationsState } from './notifications/provide-suggestion-notifications-state';
|
||||
import { ThemedPageErrorComponent } from './page-error/themed-page-error.component';
|
||||
import { ThemedPageInternalServerErrorComponent } from './page-internal-server-error/themed-page-internal-server-error.component';
|
||||
import {
|
||||
ThemedPageInternalServerErrorComponent
|
||||
} from './page-internal-server-error/themed-page-internal-server-error.component';
|
||||
import { ThemedPageNotFoundComponent } from './pagenotfound/themed-pagenotfound.component';
|
||||
import { PROCESS_MODULE_PATH } from './process-page/process-page-routing.paths';
|
||||
import { provideSubmissionState } from './submission/provide-submission-state';
|
||||
@@ -257,6 +259,20 @@ export const APP_ROUTES: Route[] = [
|
||||
.then((m) => m.ROUTES),
|
||||
canActivate: [authenticatedGuard],
|
||||
},
|
||||
{
|
||||
path: 'external-login/:token',
|
||||
loadChildren: () => import('./external-login-page/external-login-routes').then((m) => m.ROUTES)
|
||||
},
|
||||
{
|
||||
path: 'review-account/:token',
|
||||
loadChildren: () => import('./external-login-review-account-info-page/external-login-review-account-info-page-routes')
|
||||
.then((m) => m.ROUTES)
|
||||
},
|
||||
{
|
||||
path: 'email-confirmation',
|
||||
loadChildren: () => import('./external-login-email-confirmation-page/external-login-email-confirmation-page-routes')
|
||||
.then((m) => m.ROUTES)
|
||||
},
|
||||
{ path: '**', pathMatch: 'full', component: ThemedPageNotFoundComponent },
|
||||
],
|
||||
},
|
||||
|
@@ -1,29 +1,22 @@
|
||||
import { Observable } from 'rxjs';
|
||||
import {
|
||||
distinctUntilChanged,
|
||||
filter,
|
||||
map,
|
||||
switchMap,
|
||||
take,
|
||||
tap,
|
||||
} from 'rxjs/operators';
|
||||
import { distinctUntilChanged, filter, map, switchMap, take, tap, } from 'rxjs/operators';
|
||||
|
||||
import { isNotEmpty } from '../../shared/empty.util';
|
||||
import { isNotEmpty, isNotEmptyOperator } from '../../shared/empty.util';
|
||||
import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model';
|
||||
import { HALEndpointService } from '../shared/hal-endpoint.service';
|
||||
import { RequestService } from '../data/request.service';
|
||||
import { DeleteRequest, GetRequest, PostRequest } from '../data/request.models';
|
||||
import { HttpOptions } from '../dspace-rest/dspace-rest.service';
|
||||
import { getFirstCompletedRemoteData } from '../shared/operators';
|
||||
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
|
||||
import { RemoteData } from '../data/remote-data';
|
||||
import {
|
||||
GetRequest,
|
||||
PostRequest,
|
||||
} from '../data/request.models';
|
||||
import { RequestService } from '../data/request.service';
|
||||
import { RestRequest } from '../data/rest-request.model';
|
||||
import { HttpOptions } from '../dspace-rest/dspace-rest.service';
|
||||
import { HALEndpointService } from '../shared/hal-endpoint.service';
|
||||
import { getFirstCompletedRemoteData } from '../shared/operators';
|
||||
import { URLCombiner } from '../url-combiner/url-combiner';
|
||||
import { AuthStatus } from './models/auth-status.model';
|
||||
import { ShortLivedToken } from './models/short-lived-token.model';
|
||||
import { MachineToken } from './models/machine-token.model';
|
||||
import { NoContent } from '../shared/NoContent.model';
|
||||
import { sendRequest } from '../shared/request.operators';
|
||||
|
||||
/**
|
||||
* Abstract service to send authentication requests
|
||||
@@ -31,6 +24,7 @@ import { ShortLivedToken } from './models/short-lived-token.model';
|
||||
export abstract class AuthRequestService {
|
||||
protected linkName = 'authn';
|
||||
protected shortlivedtokensEndpoint = 'shortlivedtokens';
|
||||
protected machinetokenEndpoint = 'machinetokens';
|
||||
|
||||
constructor(protected halService: HALEndpointService,
|
||||
protected requestService: RequestService,
|
||||
@@ -139,4 +133,32 @@ export abstract class AuthRequestService {
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a post request to create a machine token
|
||||
*/
|
||||
public postToMachineTokenEndpoint(): Observable<RemoteData<MachineToken>> {
|
||||
return this.halService.getEndpoint(this.linkName).pipe(
|
||||
isNotEmptyOperator(),
|
||||
distinctUntilChanged(),
|
||||
map((href: string) => new URLCombiner(href, this.machinetokenEndpoint).toString()),
|
||||
map((endpointURL: string) => new PostRequest(this.requestService.generateRequestId(), endpointURL)),
|
||||
tap((request: RestRequest) => this.requestService.send(request)),
|
||||
switchMap((request: RestRequest) => this.rdbService.buildFromRequestUUID<MachineToken>(request.uuid))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a delete request to destroy a machine token
|
||||
*/
|
||||
public deleteToMachineTokenEndpoint(): Observable<RemoteData<NoContent>> {
|
||||
return this.halService.getEndpoint(this.linkName).pipe(
|
||||
isNotEmptyOperator(),
|
||||
distinctUntilChanged(),
|
||||
map((href: string) => new URLCombiner(href, this.machinetokenEndpoint).toString()),
|
||||
map((endpointURL: string) => new DeleteRequest(this.requestService.generateRequestId(), endpointURL)),
|
||||
sendRequest(this.requestService),
|
||||
switchMap((request: RestRequest) => this.rdbService.buildFromRequestUUID<MachineToken>(request.uuid)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@@ -1,33 +1,14 @@
|
||||
import { HttpHeaders } from '@angular/common/http';
|
||||
import {
|
||||
Inject,
|
||||
Injectable,
|
||||
Optional,
|
||||
} from '@angular/core';
|
||||
import { Inject, Injectable, Optional, } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import {
|
||||
select,
|
||||
Store,
|
||||
} from '@ngrx/store';
|
||||
import { select, Store, } from '@ngrx/store';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { CookieAttributes } from 'js-cookie';
|
||||
import {
|
||||
Observable,
|
||||
of as observableOf,
|
||||
} from 'rxjs';
|
||||
import {
|
||||
filter,
|
||||
map,
|
||||
startWith,
|
||||
switchMap,
|
||||
take,
|
||||
} from 'rxjs/operators';
|
||||
import { Observable, of as observableOf, } from 'rxjs';
|
||||
import { filter, map, startWith, switchMap, take, } from 'rxjs/operators';
|
||||
|
||||
import { environment } from '../../../environments/environment';
|
||||
import {
|
||||
REQUEST,
|
||||
RESPONSE,
|
||||
} from '../../../express.tokens';
|
||||
import { REQUEST, RESPONSE, } from '../../../express.tokens';
|
||||
import { AppState } from '../../app.reducer';
|
||||
import {
|
||||
hasNoValue,
|
||||
@@ -41,10 +22,7 @@ import {
|
||||
import { NotificationsService } from '../../shared/notifications/notifications.service';
|
||||
import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
|
||||
import { followLink } from '../../shared/utils/follow-link-config.model';
|
||||
import {
|
||||
buildPaginatedList,
|
||||
PaginatedList,
|
||||
} from '../data/paginated-list.model';
|
||||
import { buildPaginatedList, PaginatedList, } from '../data/paginated-list.model';
|
||||
import { RemoteData } from '../data/remote-data';
|
||||
import { HttpOptions } from '../dspace-rest/dspace-rest.service';
|
||||
import { EPersonDataService } from '../eperson/eperson-data.service';
|
||||
@@ -54,13 +32,16 @@ import { CookieService } from '../services/cookie.service';
|
||||
import { HardRedirectService } from '../services/hard-redirect.service';
|
||||
import { RouteService } from '../services/route.service';
|
||||
import {
|
||||
NativeWindowRef,
|
||||
NativeWindowService,
|
||||
} from '../services/window.service';
|
||||
import {
|
||||
getAllSucceededRemoteDataPayload,
|
||||
getFirstCompletedRemoteData,
|
||||
} from '../shared/operators';
|
||||
getAuthenticatedUserId,
|
||||
getAuthenticationToken,
|
||||
getExternalAuthCookieStatus,
|
||||
getRedirectUrl,
|
||||
isAuthenticated,
|
||||
isAuthenticatedLoaded,
|
||||
isIdle,
|
||||
isTokenRefreshing
|
||||
} from './selectors';
|
||||
import { getAllSucceededRemoteDataPayload, getFirstCompletedRemoteData, } from '../shared/operators';
|
||||
import { PageInfo } from '../shared/page-info.model';
|
||||
import {
|
||||
CheckAuthenticationTokenAction,
|
||||
@@ -74,20 +55,11 @@ import {
|
||||
import { AuthRequestService } from './auth-request.service';
|
||||
import { AuthMethod } from './models/auth.method';
|
||||
import { AuthStatus } from './models/auth-status.model';
|
||||
import {
|
||||
AuthTokenInfo,
|
||||
TOKENITEM,
|
||||
} from './models/auth-token-info.model';
|
||||
import {
|
||||
getAuthenticatedUserId,
|
||||
getAuthenticationToken,
|
||||
getExternalAuthCookieStatus,
|
||||
getRedirectUrl,
|
||||
isAuthenticated,
|
||||
isAuthenticatedLoaded,
|
||||
isIdle,
|
||||
isTokenRefreshing,
|
||||
} from './selectors';
|
||||
import { AuthTokenInfo, TOKENITEM, } from './models/auth-token-info.model';
|
||||
import { NoContent } from '../shared/NoContent.model';
|
||||
import { URLCombiner } from '../url-combiner/url-combiner';
|
||||
import { MachineToken } from './models/machine-token.model';
|
||||
import { NativeWindowRef, NativeWindowService } from '../services/window.service';
|
||||
|
||||
export const LOGIN_ROUTE = '/login';
|
||||
export const LOGOUT_ROUTE = '/logout';
|
||||
@@ -579,6 +551,31 @@ export class AuthService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the external server redirect URL.
|
||||
* @param origin - The origin route.
|
||||
* @param redirectRoute - The redirect route.
|
||||
* @param location - The location.
|
||||
* @returns The external server redirect URL.
|
||||
*/
|
||||
getExternalServerRedirectUrl(origin: string, redirectRoute: string, location: string): string {
|
||||
const correctRedirectUrl = new URLCombiner(origin, redirectRoute).toString();
|
||||
|
||||
let externalServerUrl = location;
|
||||
const myRegexp = /\?redirectUrl=(.*)/g;
|
||||
const match = myRegexp.exec(location);
|
||||
const redirectUrlFromServer = (match && match[1]) ? match[1] : null;
|
||||
|
||||
// Check whether the current page is different from the redirect url received from rest
|
||||
if (isNotNull(redirectUrlFromServer) && redirectUrlFromServer !== correctRedirectUrl) {
|
||||
// change the redirect url with the current page url
|
||||
const newRedirectUrl = `?redirectUrl=${correctRedirectUrl}`;
|
||||
externalServerUrl = location.replace(/\?redirectUrl=(.*)/g, newRedirectUrl);
|
||||
}
|
||||
|
||||
return externalServerUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear redirect url
|
||||
*/
|
||||
@@ -664,4 +661,18 @@ export class AuthService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new machine token for the current user
|
||||
*/
|
||||
public createMachineToken(): Observable<RemoteData<MachineToken>> {
|
||||
return this.authRequestService.postToMachineTokenEndpoint();
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the machine token for the current user
|
||||
*/
|
||||
public deleteMachineToken(): Observable<RemoteData<NoContent>> {
|
||||
return this.authRequestService.deleteToMachineTokenEndpoint();
|
||||
}
|
||||
|
||||
}
|
||||
|
@@ -5,5 +5,5 @@ export enum AuthMethodType {
|
||||
Ip = 'ip',
|
||||
X509 = 'x509',
|
||||
Oidc = 'oidc',
|
||||
Orcid = 'orcid'
|
||||
Orcid = 'orcid',
|
||||
}
|
||||
|
4
src/app/core/auth/models/auth.registration-type.ts
Normal file
4
src/app/core/auth/models/auth.registration-type.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export enum AuthRegistrationType {
|
||||
Orcid = 'ORCID',
|
||||
Validation = 'VALIDATION_',
|
||||
}
|
36
src/app/core/auth/models/machine-token.model.ts
Normal file
36
src/app/core/auth/models/machine-token.model.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { autoserialize, autoserializeAs, deserialize } from 'cerialize';
|
||||
|
||||
import { typedObject } from '../../cache/builders/build-decorators';
|
||||
import { CacheableObject } from '../../cache/cacheable-object.model';
|
||||
import { excludeFromEquals } from '../../utilities/equals.decorators';
|
||||
import { ResourceType } from '../../shared/resource-type';
|
||||
import { HALLink } from '../../shared/hal-link.model';
|
||||
import { MACHINE_TOKEN } from './machine-token.resource-type';
|
||||
|
||||
/**
|
||||
* A machine token that can be used to authenticate a rest request
|
||||
*/
|
||||
@typedObject
|
||||
export class MachineToken implements CacheableObject {
|
||||
static type = MACHINE_TOKEN;
|
||||
/**
|
||||
* The type for this MachineToken
|
||||
*/
|
||||
@excludeFromEquals
|
||||
@autoserialize
|
||||
type: ResourceType;
|
||||
|
||||
/**
|
||||
* The value for this MachineToken
|
||||
*/
|
||||
@autoserializeAs('token')
|
||||
value: string;
|
||||
|
||||
/**
|
||||
* The {@link HALLink}s for this MachineToken
|
||||
*/
|
||||
@deserialize
|
||||
_links: {
|
||||
self: HALLink;
|
||||
};
|
||||
}
|
9
src/app/core/auth/models/machine-token.resource-type.ts
Normal file
9
src/app/core/auth/models/machine-token.resource-type.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { ResourceType } from '../../shared/resource-type';
|
||||
|
||||
/**
|
||||
* The resource type for MachineToken
|
||||
*
|
||||
* Needs to be in a separate file to prevent circular
|
||||
* dependencies in webpack.
|
||||
*/
|
||||
export const MACHINE_TOKEN = new ResourceType('machinetoken');
|
@@ -105,7 +105,7 @@ describe('EpersonRegistrationService', () => {
|
||||
|
||||
describe('searchByToken', () => {
|
||||
it('should return a registration corresponding to the provided token', () => {
|
||||
const expected = service.searchByToken('test-token');
|
||||
const expected = service.searchByTokenAndUpdateData('test-token');
|
||||
|
||||
expect(expected).toBeObservable(cold('(a|)', {
|
||||
a: jasmine.objectContaining({
|
||||
@@ -123,7 +123,7 @@ describe('EpersonRegistrationService', () => {
|
||||
testScheduler.run(({ cold, expectObservable }) => {
|
||||
rdbService.buildSingle.and.returnValue(cold('a', { a: rd }));
|
||||
|
||||
service.searchByToken('test-token');
|
||||
service.searchByTokenAndUpdateData('test-token');
|
||||
|
||||
expect(requestService.send).toHaveBeenCalledWith(
|
||||
jasmine.objectContaining({
|
||||
|
@@ -1,33 +1,22 @@
|
||||
import {
|
||||
HttpHeaders,
|
||||
HttpParams,
|
||||
} from '@angular/common/http';
|
||||
import { HttpHeaders, HttpParams, } from '@angular/common/http';
|
||||
import { Injectable } from '@angular/core';
|
||||
import { RequestService } from './request.service';
|
||||
import { HALEndpointService } from '../shared/hal-endpoint.service';
|
||||
import { GetRequest, PatchRequest, PostRequest } from './request.models';
|
||||
import { Observable } from 'rxjs';
|
||||
import {
|
||||
filter,
|
||||
find,
|
||||
map,
|
||||
} from 'rxjs/operators';
|
||||
import { filter, find, map, } from 'rxjs/operators';
|
||||
|
||||
import {
|
||||
hasValue,
|
||||
isNotEmpty,
|
||||
} from '../../shared/empty.util';
|
||||
import { hasValue, isNotEmpty, } from '../../shared/empty.util';
|
||||
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
|
||||
import { HttpOptions } from '../dspace-rest/dspace-rest.service';
|
||||
import { GenericConstructor } from '../shared/generic-constructor';
|
||||
import { HALEndpointService } from '../shared/hal-endpoint.service';
|
||||
import { getFirstCompletedRemoteData } from '../shared/operators';
|
||||
import { Registration } from '../shared/registration.model';
|
||||
import { ResponseParsingService } from './parsing.service';
|
||||
import { RegistrationResponseParsingService } from './registration-response-parsing.service';
|
||||
import { RemoteData } from './remote-data';
|
||||
import {
|
||||
GetRequest,
|
||||
PostRequest,
|
||||
} from './request.models';
|
||||
import { RequestService } from './request.service';
|
||||
import { Operation } from 'fast-json-patch';
|
||||
import { NoContent } from '../shared/NoContent.model';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
@@ -45,7 +34,6 @@ export class EpersonRegistrationService {
|
||||
protected rdbService: RemoteDataBuildService,
|
||||
protected halService: HALEndpointService,
|
||||
) {
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -103,10 +91,11 @@ export class EpersonRegistrationService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Search a registration based on the provided token
|
||||
* @param token
|
||||
* Searches for a registration based on the provided token.
|
||||
* @param token The token to search for.
|
||||
* @returns An observable of remote data containing the registration.
|
||||
*/
|
||||
searchByToken(token: string): Observable<RemoteData<Registration>> {
|
||||
searchByTokenAndUpdateData(token: string): Observable<RemoteData<Registration>> {
|
||||
const requestId = this.requestService.generateRequestId();
|
||||
|
||||
const href$ = this.getTokenSearchEndpoint(token).pipe(
|
||||
@@ -126,7 +115,11 @@ export class EpersonRegistrationService {
|
||||
return this.rdbService.buildSingle<Registration>(href$).pipe(
|
||||
map((rd) => {
|
||||
if (rd.hasSucceeded && hasValue(rd.payload)) {
|
||||
return Object.assign(rd, { payload: Object.assign(rd.payload, { token }) });
|
||||
return Object.assign(rd, { payload: Object.assign(new Registration(), {
|
||||
email: rd.payload.email,
|
||||
token: token,
|
||||
user: rd.payload.user,
|
||||
}) });
|
||||
} else {
|
||||
return rd;
|
||||
}
|
||||
@@ -134,4 +127,69 @@ export class EpersonRegistrationService {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Searches for a registration by token and handles any errors that may occur.
|
||||
* @param token The token to search for.
|
||||
* @returns An observable of remote data containing the registration.
|
||||
*/
|
||||
searchByTokenAndHandleError(token: string): Observable<RemoteData<Registration>> {
|
||||
const requestId = this.requestService.generateRequestId();
|
||||
|
||||
const href$ = this.getTokenSearchEndpoint(token).pipe(
|
||||
find((href: string) => hasValue(href)),
|
||||
);
|
||||
|
||||
href$.subscribe((href: string) => {
|
||||
const request = new GetRequest(requestId, href);
|
||||
Object.assign(request, {
|
||||
getResponseParser(): GenericConstructor<ResponseParsingService> {
|
||||
return RegistrationResponseParsingService;
|
||||
}
|
||||
});
|
||||
this.requestService.send(request, true);
|
||||
});
|
||||
return this.rdbService.buildSingle<Registration>(href$);
|
||||
}
|
||||
|
||||
/**
|
||||
* Patch the registration object to update the email address
|
||||
* @param value provided by the user during the registration confirmation process
|
||||
* @param registrationId The id of the registration object
|
||||
* @param token The token of the registration object
|
||||
* @param updateValue Flag to indicate if the email should be updated or added
|
||||
* @returns Remote Data state of the patch request
|
||||
*/
|
||||
patchUpdateRegistration(values: string[], field: string, registrationId: string, token: string, operator: 'add' | 'replace'): Observable<RemoteData<NoContent>> {
|
||||
const requestId = this.requestService.generateRequestId();
|
||||
|
||||
const href$ = this.getRegistrationEndpoint().pipe(
|
||||
find((href: string) => hasValue(href)),
|
||||
map((href: string) => `${href}/${registrationId}?token=${token}`),
|
||||
);
|
||||
|
||||
href$.subscribe((href: string) => {
|
||||
const operations = this.generateOperations(values, field, operator);
|
||||
const patchRequest = new PatchRequest(requestId, href, operations);
|
||||
this.requestService.send(patchRequest);
|
||||
});
|
||||
|
||||
return this.rdbService.buildFromRequestUUID(requestId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom method to generate the operations to be performed on the registration object
|
||||
* @param value provided by the user during the registration confirmation process
|
||||
* @param updateValue Flag to indicate if the email should be updated or added
|
||||
* @returns Operations to be performed on the registration object
|
||||
*/
|
||||
private generateOperations(values: string[], field: string, operator: 'add' | 'replace'): Operation[] {
|
||||
let operations = [];
|
||||
if (values.length > 0 && hasValue(field) ) {
|
||||
operations = [{
|
||||
op: operator, path: `/${field}`, value: values
|
||||
}];
|
||||
}
|
||||
|
||||
return operations;
|
||||
}
|
||||
}
|
||||
|
@@ -1,19 +1,8 @@
|
||||
import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
|
||||
import {
|
||||
fakeAsync,
|
||||
TestBed,
|
||||
tick,
|
||||
waitForAsync,
|
||||
} from '@angular/core/testing';
|
||||
import { fakeAsync, TestBed, tick, waitForAsync, } from '@angular/core/testing';
|
||||
import { Store } from '@ngrx/store';
|
||||
import {
|
||||
MockStore,
|
||||
provideMockStore,
|
||||
} from '@ngrx/store/testing';
|
||||
import {
|
||||
compare,
|
||||
Operation,
|
||||
} from 'fast-json-patch';
|
||||
import { MockStore, provideMockStore, } from '@ngrx/store/testing';
|
||||
import { compare, Operation, } from 'fast-json-patch';
|
||||
import { cold } from 'jasmine-marbles';
|
||||
import { of as observableOf } from 'rxjs';
|
||||
|
||||
@@ -24,21 +13,12 @@ import {
|
||||
import { getMockRemoteDataBuildServiceHrefMap } from '../../shared/mocks/remote-data-build.service.mock';
|
||||
import { getMockRequestService } from '../../shared/mocks/request.service.mock';
|
||||
import { NotificationsService } from '../../shared/notifications/notifications.service';
|
||||
import {
|
||||
createNoContentRemoteDataObject$,
|
||||
createSuccessfulRemoteDataObject$,
|
||||
} from '../../shared/remote-data.utils';
|
||||
import {
|
||||
EPersonMock,
|
||||
EPersonMock2,
|
||||
} from '../../shared/testing/eperson.mock';
|
||||
import { createNoContentRemoteDataObject$, createSuccessfulRemoteDataObject$, } from '../../shared/remote-data.utils';
|
||||
import { EPersonMock, EPersonMock2, } from '../../shared/testing/eperson.mock';
|
||||
import { GroupMock } from '../../shared/testing/group-mock';
|
||||
import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub';
|
||||
import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub';
|
||||
import {
|
||||
createPaginatedList,
|
||||
createRequestEntry$,
|
||||
} from '../../shared/testing/utils.test';
|
||||
import { createPaginatedList, createRequestEntry$, } from '../../shared/testing/utils.test';
|
||||
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
|
||||
import { RequestParam } from '../cache/models/request-param.model';
|
||||
import { ObjectCacheService } from '../cache/object-cache.service';
|
||||
@@ -46,18 +26,13 @@ import { CoreState } from '../core-state.model';
|
||||
import { ChangeAnalyzer } from '../data/change-analyzer';
|
||||
import { DSOChangeAnalyzer } from '../data/dso-change-analyzer.service';
|
||||
import { FindListOptions } from '../data/find-list-options.model';
|
||||
import {
|
||||
PatchRequest,
|
||||
PostRequest,
|
||||
} from '../data/request.models';
|
||||
import { PatchRequest, PostRequest, } from '../data/request.models';
|
||||
import { RequestService } from '../data/request.service';
|
||||
import { HALEndpointService } from '../shared/hal-endpoint.service';
|
||||
import { Item } from '../shared/item.model';
|
||||
import {
|
||||
editEPersonSelector,
|
||||
EPersonDataService,
|
||||
} from './eperson-data.service';
|
||||
import { editEPersonSelector, EPersonDataService, } from './eperson-data.service';
|
||||
import { EPerson } from './models/eperson.model';
|
||||
import { RemoteData } from '../data/remote-data';
|
||||
|
||||
describe('EPersonDataService', () => {
|
||||
let service: EPersonDataService;
|
||||
@@ -351,6 +326,21 @@ describe('EPersonDataService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('mergeEPersonDataWithToken', () => {
|
||||
const uuid = '1234-5678-9012-3456';
|
||||
const token = 'abcd-efgh-ijkl-mnop';
|
||||
const metadataKey = 'eperson.firstname';
|
||||
beforeEach(() => {
|
||||
spyOn(service, 'mergeEPersonDataWithToken').and.returnValue(createSuccessfulRemoteDataObject$(EPersonMock));
|
||||
});
|
||||
|
||||
it('should merge EPerson data with token', () => {
|
||||
service.mergeEPersonDataWithToken(uuid, token, metadataKey).subscribe((result: RemoteData<EPerson>) => {
|
||||
expect(result.hasSucceeded).toBeTrue();
|
||||
});
|
||||
expect(service.mergeEPersonDataWithToken).toHaveBeenCalledWith(uuid, token, metadataKey);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
class DummyChangeAnalyzer implements ChangeAnalyzer<Item> {
|
||||
|
@@ -1,16 +1,8 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import {
|
||||
createSelector,
|
||||
select,
|
||||
Store,
|
||||
} from '@ngrx/store';
|
||||
import { createSelector, select, Store, } from '@ngrx/store';
|
||||
import { Operation } from 'fast-json-patch';
|
||||
import { Observable } from 'rxjs';
|
||||
import {
|
||||
find,
|
||||
map,
|
||||
take,
|
||||
} from 'rxjs/operators';
|
||||
import { find, map, take, } from 'rxjs/operators';
|
||||
|
||||
import { getEPersonEditRoute } from '../../access-control/access-control-routing-paths';
|
||||
import {
|
||||
@@ -19,51 +11,27 @@ import {
|
||||
} from '../../access-control/epeople-registry/epeople-registry.actions';
|
||||
import { EPeopleRegistryState } from '../../access-control/epeople-registry/epeople-registry.reducers';
|
||||
import { AppState } from '../../app.reducer';
|
||||
import {
|
||||
hasNoValue,
|
||||
hasValue,
|
||||
} from '../../shared/empty.util';
|
||||
import { hasNoValue, hasValue, } from '../../shared/empty.util';
|
||||
import { NotificationsService } from '../../shared/notifications/notifications.service';
|
||||
import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model';
|
||||
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
|
||||
import { RequestParam } from '../cache/models/request-param.model';
|
||||
import { ObjectCacheService } from '../cache/object-cache.service';
|
||||
import {
|
||||
CreateData,
|
||||
CreateDataImpl,
|
||||
} from '../data/base/create-data';
|
||||
import {
|
||||
DeleteData,
|
||||
DeleteDataImpl,
|
||||
} from '../data/base/delete-data';
|
||||
import { CreateData, CreateDataImpl, } from '../data/base/create-data';
|
||||
import { DeleteData, DeleteDataImpl, } from '../data/base/delete-data';
|
||||
import { IdentifiableDataService } from '../data/base/identifiable-data.service';
|
||||
import {
|
||||
PatchData,
|
||||
PatchDataImpl,
|
||||
} from '../data/base/patch-data';
|
||||
import {
|
||||
SearchData,
|
||||
SearchDataImpl,
|
||||
} from '../data/base/search-data';
|
||||
import { PatchData, PatchDataImpl, } from '../data/base/patch-data';
|
||||
import { SearchData, SearchDataImpl, } from '../data/base/search-data';
|
||||
import { DSOChangeAnalyzer } from '../data/dso-change-analyzer.service';
|
||||
import { FindListOptions } from '../data/find-list-options.model';
|
||||
import {
|
||||
buildPaginatedList,
|
||||
PaginatedList,
|
||||
} from '../data/paginated-list.model';
|
||||
import { buildPaginatedList, PaginatedList, } from '../data/paginated-list.model';
|
||||
import { RemoteData } from '../data/remote-data';
|
||||
import {
|
||||
PatchRequest,
|
||||
PostRequest,
|
||||
} from '../data/request.models';
|
||||
import { PatchRequest, PostRequest, } from '../data/request.models';
|
||||
import { RequestService } from '../data/request.service';
|
||||
import { RestRequestMethod } from '../data/rest-request-method';
|
||||
import { HALEndpointService } from '../shared/hal-endpoint.service';
|
||||
import { NoContent } from '../shared/NoContent.model';
|
||||
import {
|
||||
getFirstSucceededRemoteData,
|
||||
getRemoteDataPayload,
|
||||
} from '../shared/operators';
|
||||
import { getFirstSucceededRemoteData, getRemoteDataPayload, } from '../shared/operators';
|
||||
import { PageInfo } from '../shared/page-info.model';
|
||||
import { EPerson } from './models/eperson.model';
|
||||
|
||||
@@ -394,6 +362,32 @@ export class EPersonDataService extends IdentifiableDataService<EPerson> impleme
|
||||
return this.rdbService.buildFromRequestUUID(requestId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a POST request to merge registration data related to the provided registration-token,
|
||||
* into the eperson related to the provided uuid
|
||||
* @param uuid the user uuid
|
||||
* @param token registration-token
|
||||
* @param metadataKey metadata key of the metadata field that should be overriden
|
||||
*/
|
||||
mergeEPersonDataWithToken(uuid: string, token: string, metadataKey?: string): Observable<RemoteData<EPerson>> {
|
||||
const requestId = this.requestService.generateRequestId();
|
||||
const hrefObs = this.getBrowseEndpoint().pipe(
|
||||
map((href: string) =>
|
||||
hasValue(metadataKey)
|
||||
? `${href}/${uuid}?token=${token}&override=${metadataKey}`
|
||||
: `${href}/${uuid}?token=${token}`
|
||||
)
|
||||
);
|
||||
|
||||
hrefObs.pipe(
|
||||
find((href: string) => hasValue(href)),
|
||||
).subscribe((href: string) => {
|
||||
const request = new PostRequest(requestId, href);
|
||||
this.requestService.send(request);
|
||||
});
|
||||
|
||||
return this.rdbService.buildFromRequestUUID(requestId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new object on the server, and store the response in the object cache
|
||||
|
@@ -1,11 +1,27 @@
|
||||
// eslint-disable-next-line max-classes-per-file
|
||||
import { typedObject } from '../cache/builders/build-decorators';
|
||||
import { REGISTRATION } from './registration.resource-type';
|
||||
import { ResourceType } from './resource-type';
|
||||
import { UnCacheableObject } from './uncacheable-object.model';
|
||||
import { MetadataValue } from './metadata.models';
|
||||
import { AuthRegistrationType } from '../auth/models/auth.registration-type';
|
||||
|
||||
export class RegistrationDataMetadataMap {
|
||||
[key: string]: RegistrationDataMetadataValue[];
|
||||
}
|
||||
|
||||
export class RegistrationDataMetadataValue extends MetadataValue {
|
||||
overrides?: string;
|
||||
}
|
||||
@typedObject
|
||||
export class Registration implements UnCacheableObject {
|
||||
static type = REGISTRATION;
|
||||
|
||||
/**
|
||||
* The unique identifier of this registration data
|
||||
*/
|
||||
id: string;
|
||||
|
||||
/**
|
||||
* The object type
|
||||
*/
|
||||
@@ -29,8 +45,24 @@ export class Registration implements UnCacheableObject {
|
||||
* The token linked to the registration
|
||||
*/
|
||||
groupNames: string[];
|
||||
|
||||
/**
|
||||
* The token linked to the registration
|
||||
*/
|
||||
groups: string[];
|
||||
|
||||
/**
|
||||
* The registration type (e.g. orcid, shibboleth, etc.)
|
||||
*/
|
||||
registrationType?: AuthRegistrationType;
|
||||
|
||||
/**
|
||||
* The netId of the user (e.g. for ORCID - <:orcid>)
|
||||
*/
|
||||
netId?: string;
|
||||
|
||||
/**
|
||||
* The metadata involved during the registration process
|
||||
*/
|
||||
registrationMetadata?: RegistrationDataMetadataMap;
|
||||
}
|
||||
|
@@ -0,0 +1,29 @@
|
||||
import { AuthRegistrationType } from 'src/app/core/auth/models/auth.registration-type';
|
||||
|
||||
/**
|
||||
* Map to store the external login confirmation component for the given auth method type
|
||||
*/
|
||||
const authMethodsMap = new Map();
|
||||
/**
|
||||
* Decorator to register the external login confirmation component for the given auth method type
|
||||
* @param authMethodType the type of the external login method
|
||||
*/
|
||||
export function renderExternalLoginConfirmationFor(
|
||||
authMethodType: AuthRegistrationType
|
||||
) {
|
||||
return function decorator(objectElement: any) {
|
||||
if (!objectElement) {
|
||||
return;
|
||||
}
|
||||
authMethodsMap.set(authMethodType, objectElement);
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Get the external login confirmation component for the given auth method type
|
||||
* @param authMethodType the type of the external login method
|
||||
*/
|
||||
export function getExternalLoginConfirmationType(
|
||||
authMethodType: AuthRegistrationType
|
||||
) {
|
||||
return authMethodsMap.get(authMethodType);
|
||||
}
|
@@ -0,0 +1,22 @@
|
||||
import { Component, Inject } from '@angular/core';
|
||||
import { Registration } from '../../core/shared/registration.model';
|
||||
|
||||
/**
|
||||
* This component renders a form to complete the registration process
|
||||
*/
|
||||
@Component({
|
||||
template: ''
|
||||
})
|
||||
export abstract class ExternalLoginMethodEntryComponent {
|
||||
|
||||
/**
|
||||
* The registration data object
|
||||
*/
|
||||
public registratioData: Registration;
|
||||
|
||||
constructor(
|
||||
@Inject('registrationDataProvider') protected injectedRegistrationDataObject: Registration,
|
||||
) {
|
||||
this.registratioData = injectedRegistrationDataObject;
|
||||
}
|
||||
}
|
@@ -0,0 +1,37 @@
|
||||
<h4 class="mb-3">
|
||||
{{ "external-login.confirm-email.header" | translate }}
|
||||
</h4>
|
||||
|
||||
<form [formGroup]="emailForm" (ngSubmit)="submitForm()">
|
||||
<div class="form-group">
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
formControlName="email"
|
||||
placeholder="profile.email@example.com"
|
||||
class="form-control form-control-lg position-relative"
|
||||
/>
|
||||
<div
|
||||
*ngIf="
|
||||
emailForm.get('email').hasError('required') &&
|
||||
emailForm.get('email').touched
|
||||
"
|
||||
class="text-danger"
|
||||
>
|
||||
{{ "external-login.confirmation.email-required" | translate }}
|
||||
</div>
|
||||
<div
|
||||
*ngIf="
|
||||
emailForm.get('email').hasError('email') &&
|
||||
emailForm.get('email').touched
|
||||
"
|
||||
class="text-danger"
|
||||
>
|
||||
{{ "external-login.confirmation.email-invalid" | translate }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-lg btn-primary btn-block">
|
||||
{{ "external-login.confirm.button.label" | translate }}
|
||||
</button>
|
||||
</form>
|
@@ -0,0 +1,161 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { ConfirmEmailComponent } from './confirm-email.component';
|
||||
import { FormBuilder, ReactiveFormsModule } from '@angular/forms';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { TranslateLoader, TranslateModule, TranslateService, } from '@ngx-translate/core';
|
||||
import { EventEmitter, NO_ERRORS_SCHEMA } from '@angular/core';
|
||||
import { ExternalLoginService } from '../../services/external-login.service';
|
||||
import { of } from 'rxjs';
|
||||
import { AuthService } from '../../../core/auth/auth.service';
|
||||
import { AuthMethodType } from '../../../core/auth/models/auth.method-type';
|
||||
import { EPersonDataService } from '../../../core/eperson/eperson-data.service';
|
||||
import { EPerson } from '../../../core/eperson/models/eperson.model';
|
||||
import { HardRedirectService } from '../../../core/services/hard-redirect.service';
|
||||
import { Registration } from '../../../core/shared/registration.model';
|
||||
import { TranslateLoaderMock } from '../../../shared/mocks/translate-loader.mock';
|
||||
import { NotificationsService } from '../../../shared/notifications/notifications.service';
|
||||
import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils';
|
||||
import { NativeWindowService } from '../../../core/services/window.service';
|
||||
import { MockWindow, NativeWindowMockFactory } from '../../../shared/mocks/mock-native-window-ref';
|
||||
import { By } from '@angular/platform-browser';
|
||||
|
||||
describe('ConfirmEmailComponent', () => {
|
||||
let component: ConfirmEmailComponent;
|
||||
let fixture: ComponentFixture<ConfirmEmailComponent>;
|
||||
let externalLoginServiceSpy: jasmine.SpyObj<ExternalLoginService>;
|
||||
let epersonDataServiceSpy: jasmine.SpyObj<EPersonDataService>;
|
||||
let notificationServiceSpy: jasmine.SpyObj<NotificationsService>;
|
||||
let authServiceSpy: jasmine.SpyObj<AuthService>;
|
||||
let hardRedirectService: HardRedirectService;
|
||||
|
||||
const translateServiceStub = {
|
||||
get: () => of(''),
|
||||
onLangChange: new EventEmitter(),
|
||||
onTranslationChange: new EventEmitter(),
|
||||
onDefaultLangChange: new EventEmitter(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
externalLoginServiceSpy = jasmine.createSpyObj('ExternalLoginService', [
|
||||
'patchUpdateRegistration',
|
||||
'getExternalAuthLocation',
|
||||
]);
|
||||
epersonDataServiceSpy = jasmine.createSpyObj('EPersonDataService', [
|
||||
'createEPersonForToken',
|
||||
]);
|
||||
notificationServiceSpy = jasmine.createSpyObj('NotificationsService', [
|
||||
'error',
|
||||
]);
|
||||
authServiceSpy = jasmine.createSpyObj('AuthService', ['getRedirectUrl', 'setRedirectUrl', 'getExternalServerRedirectUrl']);
|
||||
hardRedirectService = jasmine.createSpyObj('HardRedirectService', {
|
||||
redirect: {},
|
||||
});
|
||||
await TestBed.configureTestingModule({
|
||||
declarations: [ConfirmEmailComponent],
|
||||
providers: [
|
||||
FormBuilder,
|
||||
{ provide: NativeWindowService, useFactory: NativeWindowMockFactory },
|
||||
{ provide: ExternalLoginService, useValue: externalLoginServiceSpy },
|
||||
{ provide: EPersonDataService, useValue: epersonDataServiceSpy },
|
||||
{ provide: NotificationsService, useValue: notificationServiceSpy },
|
||||
{ provide: AuthService, useValue: authServiceSpy },
|
||||
{ provide: TranslateService, useValue: translateServiceStub },
|
||||
{ provide: HardRedirectService, useValue: hardRedirectService },
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
TranslateModule.forRoot({
|
||||
loader: {
|
||||
provide: TranslateLoader,
|
||||
useClass: TranslateLoaderMock,
|
||||
},
|
||||
}),
|
||||
ReactiveFormsModule
|
||||
],
|
||||
schemas: [NO_ERRORS_SCHEMA],
|
||||
}).compileComponents();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(ConfirmEmailComponent);
|
||||
component = fixture.componentInstance;
|
||||
component.registrationData = Object.assign(new Registration(), {
|
||||
id: '123',
|
||||
email: 'test@example.com',
|
||||
netId: 'test-netid',
|
||||
registrationMetadata: {},
|
||||
registrationType: AuthMethodType.Orcid,
|
||||
});
|
||||
component.token = 'test-token';
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should show email from registration data', () => {
|
||||
fixture.detectChanges();
|
||||
const emailInput = fixture.debugElement.query(By.css('input[id=email]'));
|
||||
expect(emailInput).toBeTruthy();
|
||||
expect(emailInput.nativeElement.value).toBe('test@example.com');
|
||||
});
|
||||
|
||||
describe('submitForm', () => {
|
||||
it('should call postCreateAccountFromToken if email is confirmed', () => {
|
||||
component.emailForm.setValue({ email: 'test@example.com' });
|
||||
spyOn(component as any, 'postCreateAccountFromToken');
|
||||
component.submitForm();
|
||||
expect(
|
||||
(component as any).postCreateAccountFromToken
|
||||
).toHaveBeenCalledWith('test-token', component.registrationData);
|
||||
});
|
||||
|
||||
it('should call patchUpdateRegistration if email is not confirmed', () => {
|
||||
component.emailForm.setValue({ email: 'new-email@example.com' });
|
||||
spyOn(component as any, 'patchUpdateRegistration');
|
||||
component.submitForm();
|
||||
expect((component as any).patchUpdateRegistration).toHaveBeenCalledWith([
|
||||
'new-email@example.com',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should not call any methods if form is invalid', () => {
|
||||
component.emailForm.setValue({ email: 'invalid-email' });
|
||||
spyOn(component as any, 'postCreateAccountFromToken');
|
||||
spyOn(component as any, 'patchUpdateRegistration');
|
||||
component.submitForm();
|
||||
expect(
|
||||
(component as any).postCreateAccountFromToken
|
||||
).not.toHaveBeenCalled();
|
||||
expect((component as any).patchUpdateRegistration).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('postCreateAccountFromToken', () => {
|
||||
it('should call NotificationsService.error if the registration data does not have a netId', () => {
|
||||
component.registrationData.netId = undefined;
|
||||
(component as any).postCreateAccountFromToken('test-token', component.registrationData);
|
||||
expect(notificationServiceSpy.error).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should call EPersonDataService.createEPersonForToken and ExternalLoginService.getExternalAuthLocation if the registration data has a netId', () => {
|
||||
externalLoginServiceSpy.getExternalAuthLocation.and.returnValue(of('test-location'));
|
||||
authServiceSpy.getRedirectUrl.and.returnValue(of('/test-redirect'));
|
||||
authServiceSpy.getExternalServerRedirectUrl.and.returnValue('test-external-url');
|
||||
epersonDataServiceSpy.createEPersonForToken.and.returnValue(createSuccessfulRemoteDataObject$(new EPerson()));
|
||||
(component as any).postCreateAccountFromToken('test-token', component.registrationData);
|
||||
expect(epersonDataServiceSpy.createEPersonForToken).toHaveBeenCalled();
|
||||
expect(externalLoginServiceSpy.getExternalAuthLocation).toHaveBeenCalledWith(AuthMethodType.Orcid);
|
||||
expect(authServiceSpy.getRedirectUrl).toHaveBeenCalled();
|
||||
expect(authServiceSpy.setRedirectUrl).toHaveBeenCalledWith('/profile');
|
||||
expect(authServiceSpy.getExternalServerRedirectUrl).toHaveBeenCalledWith(MockWindow.origin,'/test-redirect', 'test-location');
|
||||
expect(hardRedirectService.redirect).toHaveBeenCalledWith('test-external-url');
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fixture.destroy();
|
||||
});
|
||||
});
|
@@ -0,0 +1,160 @@
|
||||
import { ChangeDetectionStrategy, Component, Inject, Input, OnDestroy, OnInit } from '@angular/core';
|
||||
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||
import { ExternalLoginService } from '../../services/external-login.service';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import isEqual from 'lodash/isEqual';
|
||||
import { combineLatest, Subscription, take } from 'rxjs';
|
||||
import { AuthService } from '../../../core/auth/auth.service';
|
||||
import { EPersonDataService } from '../../../core/eperson/eperson-data.service';
|
||||
import { EPerson } from '../../../core/eperson/models/eperson.model';
|
||||
import { HardRedirectService } from '../../../core/services/hard-redirect.service';
|
||||
import { getFirstCompletedRemoteData, getRemoteDataPayload } from '../../../core/shared/operators';
|
||||
import { Registration } from '../../../core/shared/registration.model';
|
||||
import { hasNoValue, hasValue } from '../../../shared/empty.util';
|
||||
import { NotificationsService } from '../../../shared/notifications/notifications.service';
|
||||
import { NativeWindowRef, NativeWindowService } from '../../../core/services/window.service';
|
||||
|
||||
@Component({
|
||||
selector: 'ds-confirm-email',
|
||||
templateUrl: './confirm-email.component.html',
|
||||
styleUrls: ['./confirm-email.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class ConfirmEmailComponent implements OnInit, OnDestroy {
|
||||
/**
|
||||
* The form containing the email input
|
||||
*/
|
||||
emailForm: FormGroup;
|
||||
/**
|
||||
* The registration data object
|
||||
*/
|
||||
@Input() registrationData: Registration;
|
||||
|
||||
/**
|
||||
* The token to be used to confirm the registration
|
||||
*/
|
||||
@Input() token: string;
|
||||
/**
|
||||
* The subscriptions to unsubscribe from
|
||||
*/
|
||||
subs: Subscription[] = [];
|
||||
|
||||
externalLocation: string;
|
||||
|
||||
constructor(
|
||||
@Inject(NativeWindowService) protected _window: NativeWindowRef,
|
||||
private formBuilder: FormBuilder,
|
||||
private externalLoginService: ExternalLoginService,
|
||||
private epersonDataService: EPersonDataService,
|
||||
private notificationService: NotificationsService,
|
||||
private translate: TranslateService,
|
||||
private authService: AuthService,
|
||||
private hardRedirectService: HardRedirectService,
|
||||
) {
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.emailForm = this.formBuilder.group({
|
||||
email: [this.registrationData.email, [Validators.required, Validators.email]]
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Submits the email form and performs appropriate actions based on the form's validity and user input.
|
||||
* If the form is valid and the confirmed email matches the registration email, calls the postCreateAccountFromToken method with the token and registration data.
|
||||
* If the form is valid but the confirmed email does not match the registration email, calls the patchUpdateRegistration method with the confirmed email.
|
||||
*/
|
||||
submitForm() {
|
||||
this.emailForm.markAllAsTouched();
|
||||
if (this.emailForm.valid) {
|
||||
const confirmedEmail = this.emailForm.get('email').value;
|
||||
if (confirmedEmail && isEqual(this.registrationData.email, confirmedEmail.trim())) {
|
||||
this.postCreateAccountFromToken(this.token, this.registrationData);
|
||||
} else {
|
||||
this.patchUpdateRegistration([confirmedEmail]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a PATCH request to update the user's registration with the given values.
|
||||
* @param values - The values to update the user's registration with.
|
||||
* @returns An Observable that emits the updated registration data.
|
||||
*/
|
||||
private patchUpdateRegistration(values: string[]) {
|
||||
this.subs.push(
|
||||
this.externalLoginService.patchUpdateRegistration(values, 'email', this.registrationData.id, this.token, 'replace')
|
||||
.pipe(getRemoteDataPayload())
|
||||
.subscribe());
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new user from a given token and registration data.
|
||||
* Based on the registration data, the user will be created with the following properties:
|
||||
* - email: the email address from the registration data
|
||||
* - metadata: all metadata values from the registration data, except for the email metadata key (ePerson object does not have an email metadata field)
|
||||
* - canLogIn: true
|
||||
* - requireCertificate: false
|
||||
* @param token The token used to create the user.
|
||||
* @param registrationData The registration data used to create the user.
|
||||
* @returns An Observable that emits a boolean indicating whether the user creation was successful.
|
||||
*/
|
||||
private postCreateAccountFromToken(
|
||||
token: string,
|
||||
registrationData: Registration
|
||||
) {
|
||||
// check if the netId is present
|
||||
// in order to create an account, the netId is required (since the user is created without a password)
|
||||
if (hasNoValue(this.registrationData.netId)) {
|
||||
this.notificationService.error(this.translate.get('external-login-page.confirm-email.create-account.notifications.error.no-netId'));
|
||||
return;
|
||||
}
|
||||
|
||||
const metadataValues = {};
|
||||
for (const [key, value] of Object.entries(registrationData.registrationMetadata)) {
|
||||
// exclude the email metadata key, since the ePerson object does not have an email metadata field
|
||||
if (hasValue(value[0]?.value) && key !== 'email') {
|
||||
metadataValues[key] = value;
|
||||
}
|
||||
}
|
||||
const eperson = new EPerson();
|
||||
eperson.email = registrationData.email;
|
||||
eperson.netid = registrationData.netId;
|
||||
eperson.metadata = metadataValues;
|
||||
eperson.canLogIn = true;
|
||||
eperson.requireCertificate = false;
|
||||
eperson.selfRegistered = true;
|
||||
this.subs.push(
|
||||
combineLatest([
|
||||
this.epersonDataService.createEPersonForToken(eperson, token).pipe(
|
||||
getFirstCompletedRemoteData(),
|
||||
),
|
||||
this.externalLoginService.getExternalAuthLocation(this.registrationData.registrationType),
|
||||
this.authService.getRedirectUrl().pipe(take(1))
|
||||
])
|
||||
.subscribe(([rd, location, redirectRoute]) => {
|
||||
if (rd.hasFailed) {
|
||||
this.notificationService.error(
|
||||
this.translate.get('external-login-page.provide-email.create-account.notifications.error.header'),
|
||||
this.translate.get('external-login-page.provide-email.create-account.notifications.error.content')
|
||||
);
|
||||
} else if (rd.hasSucceeded) {
|
||||
// set Redirect URL to User profile, so the user is redirected to the profile page after logging in
|
||||
this.authService.setRedirectUrl('/profile');
|
||||
const externalServerUrl = this.authService.getExternalServerRedirectUrl(
|
||||
this._window.nativeWindow.origin,
|
||||
redirectRoute,
|
||||
location
|
||||
);
|
||||
// redirect to external registration type authentication url
|
||||
this.hardRedirectService.redirect(externalServerUrl);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.subs.filter(sub => hasValue(sub)).forEach((sub) => sub.unsubscribe());
|
||||
}
|
||||
}
|
@@ -0,0 +1,5 @@
|
||||
<h4>
|
||||
{{ "external-login.confirm-email-sent.header" | translate }}
|
||||
</h4>
|
||||
|
||||
<p class="mt-4" [innerHTML]="'external-login.confirm-email-sent.info' | translate"></p>
|
@@ -0,0 +1,63 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { ConfirmationSentComponent } from './confirmation-sent.component';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { CUSTOM_ELEMENTS_SCHEMA, EventEmitter } from '@angular/core';
|
||||
import { TranslateLoader, TranslateModule, TranslateService } from '@ngx-translate/core';
|
||||
import { of } from 'rxjs';
|
||||
import { TranslateLoaderMock } from '../../../shared/mocks/translate-loader.mock';
|
||||
|
||||
describe('ConfirmationSentComponent', () => {
|
||||
let component: ConfirmationSentComponent;
|
||||
let fixture: ComponentFixture<ConfirmationSentComponent>;
|
||||
let compiledTemplate: HTMLElement;
|
||||
|
||||
const translateServiceStub = {
|
||||
get: () => of('Mocked Translation Text'),
|
||||
instant: (key: any) => 'Mocked Translation Text',
|
||||
onLangChange: new EventEmitter(),
|
||||
onTranslationChange: new EventEmitter(),
|
||||
onDefaultLangChange: new EventEmitter()
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
declarations: [ ConfirmationSentComponent ],
|
||||
providers: [
|
||||
{ provide: TranslateService, useValue: translateServiceStub },
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
TranslateModule.forRoot({
|
||||
loader: {
|
||||
provide: TranslateLoader,
|
||||
useClass: TranslateLoaderMock
|
||||
}
|
||||
}),
|
||||
],
|
||||
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||
})
|
||||
.compileComponents();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(ConfirmationSentComponent);
|
||||
component = fixture.componentInstance;
|
||||
compiledTemplate = fixture.nativeElement;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should render translated header', () => {
|
||||
const headerElement = compiledTemplate.querySelector('h4');
|
||||
expect(headerElement.textContent).toContain('Mocked Translation Text');
|
||||
});
|
||||
|
||||
it('should render translated info paragraph', () => {
|
||||
const infoParagraphElement = compiledTemplate.querySelector('p');
|
||||
expect(infoParagraphElement.innerHTML).toBeTruthy();
|
||||
});
|
||||
});
|
@@ -0,0 +1,9 @@
|
||||
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'ds-confirmation-sent',
|
||||
templateUrl: './confirmation-sent.component.html',
|
||||
styleUrls: ['./confirmation-sent.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class ConfirmationSentComponent { }
|
@@ -0,0 +1,38 @@
|
||||
<h4 class="mb-3">
|
||||
{{ "external-login.provide-email.header" | translate }}
|
||||
</h4>
|
||||
|
||||
<form [formGroup]="emailForm" (ngSubmit)="submitForm()">
|
||||
<div class="form-group">
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
formControlName="email"
|
||||
placeholder="Input box"
|
||||
class="form-control form-control-lg position-relative"
|
||||
/>
|
||||
|
||||
<div
|
||||
*ngIf="
|
||||
emailForm.get('email').hasError('required') &&
|
||||
emailForm.get('email').touched
|
||||
"
|
||||
class="text-danger"
|
||||
>
|
||||
{{ "external-login.confirmation.email-required" | translate }}
|
||||
</div>
|
||||
<div
|
||||
*ngIf="
|
||||
emailForm.get('email').hasError('email') &&
|
||||
emailForm.get('email').touched
|
||||
"
|
||||
class="text-danger"
|
||||
>
|
||||
{{ "external-login.confirmation.email-invalid" | translate }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-lg btn-primary btn-block">
|
||||
{{ "external-login.provide-email.button.label" | translate }}
|
||||
</button>
|
||||
</form>
|
@@ -0,0 +1,62 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { ProvideEmailComponent } from './provide-email.component';
|
||||
import { FormBuilder } from '@angular/forms';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
|
||||
import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
|
||||
import { ExternalLoginService } from '../../services/external-login.service';
|
||||
import { TranslateLoaderMock } from '../../../shared/mocks/translate-loader.mock';
|
||||
|
||||
describe('ProvideEmailComponent', () => {
|
||||
let component: ProvideEmailComponent;
|
||||
let fixture: ComponentFixture<ProvideEmailComponent>;
|
||||
let externalLoginServiceSpy: jasmine.SpyObj<ExternalLoginService>;
|
||||
|
||||
beforeEach(async () => {
|
||||
const externalLoginService = jasmine.createSpyObj('ExternalLoginService', ['patchUpdateRegistration']);
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
declarations: [ ProvideEmailComponent ],
|
||||
providers: [
|
||||
FormBuilder,
|
||||
{ provide: ExternalLoginService, useValue: externalLoginService },
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
TranslateModule.forRoot({
|
||||
loader: {
|
||||
provide: TranslateLoader,
|
||||
useClass: TranslateLoaderMock
|
||||
}
|
||||
}),
|
||||
],
|
||||
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||
})
|
||||
.compileComponents();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(ProvideEmailComponent);
|
||||
component = fixture.componentInstance;
|
||||
externalLoginServiceSpy = TestBed.inject(ExternalLoginService) as jasmine.SpyObj<ExternalLoginService>;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
// it('should call externalLoginService.patchUpdateRegistration when form is submitted with valid email', () => {
|
||||
// const email = 'test@example.com';
|
||||
// component.emailForm.setValue({ email });
|
||||
// component.registrationId = '123';
|
||||
// component.token = '456';
|
||||
// fixture.detectChanges();
|
||||
|
||||
// const button = fixture.nativeElement.querySelector('button[type="submit"]');
|
||||
// button.click();
|
||||
|
||||
// expect(externalLoginServiceSpy.patchUpdateRegistration).toHaveBeenCalledWith([email], 'email', component.registrationId, component.token, 'add');
|
||||
// });
|
||||
});
|
@@ -0,0 +1,56 @@
|
||||
import { ChangeDetectionStrategy, Component, Input, OnDestroy } from '@angular/core';
|
||||
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||
import { ExternalLoginService } from '../../services/external-login.service';
|
||||
import { Subscription } from 'rxjs';
|
||||
import { hasValue } from '../../../shared/empty.util';
|
||||
|
||||
@Component({
|
||||
selector: 'ds-provide-email',
|
||||
templateUrl: './provide-email.component.html',
|
||||
styleUrls: ['./provide-email.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ProvideEmailComponent implements OnDestroy {
|
||||
/**
|
||||
* The form group for the email input
|
||||
*/
|
||||
emailForm: FormGroup;
|
||||
/**
|
||||
* The registration id
|
||||
*/
|
||||
@Input() registrationId: string;
|
||||
/**
|
||||
* The token from the URL
|
||||
*/
|
||||
@Input() token: string;
|
||||
/**
|
||||
* The subscriptions to unsubscribe from
|
||||
*/
|
||||
subs: Subscription[] = [];
|
||||
|
||||
constructor(
|
||||
private formBuilder: FormBuilder,
|
||||
private externalLoginService: ExternalLoginService,
|
||||
) {
|
||||
this.emailForm = this.formBuilder.group({
|
||||
email: ['', [Validators.required, Validators.email]],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the user's email in the registration data.
|
||||
* @returns void
|
||||
*/
|
||||
submitForm() {
|
||||
this.emailForm.markAllAsTouched();
|
||||
if (this.emailForm.valid) {
|
||||
const email = this.emailForm.get('email').value;
|
||||
this.subs.push(this.externalLoginService.patchUpdateRegistration([email], 'email', this.registrationId, this.token, 'add')
|
||||
.subscribe());
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.subs.filter(sub => hasValue(sub)).forEach((sub) => sub.unsubscribe());
|
||||
}
|
||||
}
|
@@ -0,0 +1,49 @@
|
||||
<div class="row">
|
||||
<h4>{{ 'external-login.confirmation.header' | translate}}</h4>
|
||||
</div>
|
||||
<div class="row justify-content-center">
|
||||
<ng-container *ngComponentOutlet="getExternalLoginConfirmationType(); injector: objectInjector;">
|
||||
</ng-container>
|
||||
</div>
|
||||
<ds-alert class="row mt-2" [type]="AlertTypeEnum.Info" [attr.data-test]="'info-text'">
|
||||
{{ informationText }}
|
||||
</ds-alert>
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-4 d-flex justify-content-end align-items-center">
|
||||
<ng-container *ngIf="registrationData.email; else provideEmail">
|
||||
<ds-confirm-email [registrationData]="registrationData" [token]="token"></ds-confirm-email>
|
||||
</ng-container>
|
||||
<ng-template #provideEmail>
|
||||
<ds-provide-email [registrationId]="registrationData.id" [token]="token"></ds-provide-email>
|
||||
</ng-template>
|
||||
</div>
|
||||
<div class="col-1 align-items-center d-flex justify-content-center">
|
||||
<h4 class="mt-2">or</h4>
|
||||
</div>
|
||||
<div class="col-4 align-items-center d-flex justify-content-start">
|
||||
<button class="btn block btn-lg btn-primary" (click)="openLoginModal(loginModal)">
|
||||
{{'external-login.connect-to-existing-account.label' | translate}}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ng-template #loginModal let-c="close" let-d="dismiss">
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title text-info"> {{'external-login.connect-to-existing-account.label' | translate}}</h4>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="row justify-content-center">
|
||||
<ds-log-in
|
||||
[excludedAuthMethod]="relatedAuthMethod"
|
||||
[showRegisterLink]="false"
|
||||
[isStandalonePage]="true"
|
||||
></ds-log-in>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-primary btn-sm" (click)="c('Close click');clearRedirectUrl()">
|
||||
<i class="fa fa-times" aria-hidden="true"></i>
|
||||
{{'external-login.modal.label.close' | translate}}
|
||||
</button>
|
||||
</div>
|
||||
</ng-template>
|
@@ -0,0 +1,106 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { ExternalLogInComponent } from './external-log-in.component';
|
||||
import { TranslateModule, TranslateService } from '@ngx-translate/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { EventEmitter } from '@angular/core';
|
||||
import { By } from '@angular/platform-browser';
|
||||
import { of as observableOf } from 'rxjs';
|
||||
import { FormBuilder } from '@angular/forms';
|
||||
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { AuthRegistrationType } from '../../core/auth/models/auth.registration-type';
|
||||
import { MetadataValue } from '../../core/shared/metadata.models';
|
||||
import { AuthService } from '../../core/auth/auth.service';
|
||||
import { AuthServiceMock } from '../../shared/mocks/auth.service.mock';
|
||||
import { Registration } from '../../core/shared/registration.model';
|
||||
import { OrcidConfirmationComponent } from '../registration-types/orcid-confirmation/orcid-confirmation.component';
|
||||
import { BrowserOnlyPipe } from '../../shared/utils/browser-only.pipe';
|
||||
|
||||
describe('ExternalLogInComponent', () => {
|
||||
let component: ExternalLogInComponent;
|
||||
let fixture: ComponentFixture<ExternalLogInComponent>;
|
||||
let modalService: NgbModal = jasmine.createSpyObj('modalService', ['open']);
|
||||
|
||||
const registrationDataMock = {
|
||||
id: '3',
|
||||
email: 'user@institution.edu',
|
||||
user: '028dcbb8-0da2-4122-a0ea-254be49ca107',
|
||||
registrationType: AuthRegistrationType.Orcid,
|
||||
netId: '0000-1111-2222-3333',
|
||||
registrationMetadata: {
|
||||
'eperson.firstname': [
|
||||
Object.assign(new MetadataValue(), {
|
||||
value: 'User 1',
|
||||
language: null,
|
||||
authority: '',
|
||||
confidence: -1,
|
||||
place: -1,
|
||||
}),
|
||||
],
|
||||
}
|
||||
};
|
||||
const translateServiceStub = {
|
||||
get: () => observableOf('Info Text'),
|
||||
instant: (key: any) => 'Info Text',
|
||||
onLangChange: new EventEmitter(),
|
||||
onTranslationChange: new EventEmitter(),
|
||||
onDefaultLangChange: new EventEmitter()
|
||||
};
|
||||
|
||||
beforeEach(() =>
|
||||
TestBed.configureTestingModule({
|
||||
imports: [CommonModule, TranslateModule.forRoot({})],
|
||||
declarations: [BrowserOnlyPipe, ExternalLogInComponent, OrcidConfirmationComponent],
|
||||
providers: [
|
||||
{ provide: TranslateService, useValue: translateServiceStub },
|
||||
{ provide: AuthService, useValue: new AuthServiceMock() },
|
||||
{ provide: NgbModal, useValue: modalService },
|
||||
FormBuilder
|
||||
]
|
||||
}).compileComponents()
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(ExternalLogInComponent);
|
||||
component = fixture.componentInstance;
|
||||
component.registrationData = Object.assign(new Registration(), registrationDataMock);
|
||||
component.registrationType = registrationDataMock.registrationType;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
component.registrationData = Object.assign(new Registration(), registrationDataMock, { email: 'user@institution.edu' });
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should set registrationType and informationText correctly when email is present', () => {
|
||||
expect(component.registrationType).toBe(registrationDataMock.registrationType);
|
||||
expect(component.informationText).toBeDefined();
|
||||
});
|
||||
|
||||
it('should render the template to confirm email when registrationData has email', () => {
|
||||
const selector = fixture.nativeElement.querySelector('ds-confirm-email');
|
||||
const provideEmail = fixture.nativeElement.querySelector('ds-provide-email');
|
||||
expect(selector).toBeTruthy();
|
||||
expect(provideEmail).toBeNull();
|
||||
});
|
||||
|
||||
it('should display login modal when connect to existing account button is clicked', () => {
|
||||
const button = fixture.nativeElement.querySelector('button.btn-primary');
|
||||
button.click();
|
||||
expect(modalService.open).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should render the template with the translated informationText', () => {
|
||||
component.informationText = 'Info Text';
|
||||
fixture.detectChanges();
|
||||
const infoText = fixture.debugElement.query(By.css('[data-test="info-text"]'));
|
||||
expect(infoText.nativeElement.innerHTML).toContain('Info Text');
|
||||
});
|
||||
});
|
||||
|
||||
|
@@ -0,0 +1,149 @@
|
||||
import { ChangeDetectionStrategy, Component, Injector, Input, OnDestroy, OnInit, } from '@angular/core';
|
||||
import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { AuthService } from '../../core/auth/auth.service';
|
||||
import { AuthMethodType } from '../../core/auth/models/auth.method-type';
|
||||
import { AuthRegistrationType } from '../../core/auth/models/auth.registration-type';
|
||||
import { Registration } from '../../core/shared/registration.model';
|
||||
import { hasValue, isEmpty } from '../../shared/empty.util';
|
||||
import { getExternalLoginConfirmationType } from '../decorators/external-log-in.methods-decorator';
|
||||
import { AlertType } from '../../shared/alert/alert-type';
|
||||
|
||||
@Component({
|
||||
selector: 'ds-external-log-in',
|
||||
templateUrl: './external-log-in.component.html',
|
||||
styleUrls: ['./external-log-in.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ExternalLogInComponent implements OnInit, OnDestroy {
|
||||
/**
|
||||
* The AlertType enumeration for access in the component's template
|
||||
* @type {AlertType}
|
||||
*/
|
||||
public AlertTypeEnum = AlertType;
|
||||
|
||||
/**
|
||||
* The type of registration type to be confirmed
|
||||
*/
|
||||
registrationType: AuthRegistrationType;
|
||||
/**
|
||||
* The registration data object
|
||||
*/
|
||||
@Input() registrationData: Registration;
|
||||
/**
|
||||
* The token to be used to confirm the registration
|
||||
* @memberof ExternalLogInComponent
|
||||
*/
|
||||
@Input() token: string;
|
||||
/**
|
||||
* The information text to be displayed,
|
||||
* depending on the registration type and the presence of an email
|
||||
* @memberof ExternalLogInComponent
|
||||
*/
|
||||
public informationText = '';
|
||||
/**
|
||||
* Injector to inject a registration data to the component with the @Input registrationType
|
||||
* @type {Injector}
|
||||
*/
|
||||
public objectInjector: Injector;
|
||||
|
||||
/**
|
||||
* Reference to NgbModal
|
||||
*/
|
||||
public modalRef: NgbModalRef;
|
||||
|
||||
/**
|
||||
* Authentication method related to registration type
|
||||
*/
|
||||
relatedAuthMethod: AuthMethodType;
|
||||
|
||||
constructor(
|
||||
private injector: Injector,
|
||||
private translate: TranslateService,
|
||||
private modalService: NgbModal,
|
||||
private authService: AuthService,
|
||||
) { }
|
||||
|
||||
/**
|
||||
* Provide the registration data object to the objectInjector.
|
||||
* Generate the information text to be displayed.
|
||||
*/
|
||||
ngOnInit(): void {
|
||||
this.objectInjector = Injector.create({
|
||||
providers: [
|
||||
{
|
||||
provide: 'registrationDataProvider',
|
||||
useFactory: () => this.registrationData,
|
||||
deps: [],
|
||||
},
|
||||
],
|
||||
parent: this.injector,
|
||||
});
|
||||
this.registrationType = this.registrationData?.registrationType ?? null;
|
||||
this.relatedAuthMethod = isEmpty(this.registrationType) ? null : this.registrationType.replace('VALIDATION_', '').toLocaleLowerCase() as AuthMethodType;
|
||||
this.informationText = hasValue(this.registrationData?.email)
|
||||
? this.generateInformationTextWhenEmail(this.registrationType)
|
||||
: this.generateInformationTextWhenNOEmail(this.registrationType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the information text to be displayed when the user has no email
|
||||
* @param authMethod the registration type
|
||||
*/
|
||||
private generateInformationTextWhenNOEmail(authMethod: string): string {
|
||||
if (authMethod) {
|
||||
const authMethodUppercase = authMethod.toUpperCase();
|
||||
return this.translate.instant('external-login.noEmail.informationText', {
|
||||
authMethod: authMethodUppercase,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the information text to be displayed when the user has an email
|
||||
* @param authMethod the registration type
|
||||
*/
|
||||
private generateInformationTextWhenEmail(authMethod: string): string {
|
||||
if (authMethod) {
|
||||
const authMethodUppercase = authMethod.toUpperCase();
|
||||
return this.translate.instant(
|
||||
'external-login.haveEmail.informationText',
|
||||
{ authMethod: authMethodUppercase }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the registration type to be rendered
|
||||
*/
|
||||
getExternalLoginConfirmationType() {
|
||||
return getExternalLoginConfirmationType(this.registrationType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the login modal and sets the redirect URL to '/review-account'.
|
||||
* On modal dismissed/closed, the redirect URL is cleared.
|
||||
* @param content - The content to be displayed in the modal.
|
||||
*/
|
||||
openLoginModal(content: any) {
|
||||
setTimeout(() => {
|
||||
this.authService.setRedirectUrl(`/review-account/${this.token}`);
|
||||
}, 100);
|
||||
this.modalRef = this.modalService.open(content);
|
||||
|
||||
this.modalRef.dismissed.subscribe(() => {
|
||||
this.clearRedirectUrl();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the redirect URL stored in the authentication service.
|
||||
*/
|
||||
clearRedirectUrl() {
|
||||
this.authService.clearRedirectUrl();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.modalRef?.close();
|
||||
}
|
||||
}
|
35
src/app/external-log-in/external-login.module.ts
Normal file
35
src/app/external-log-in/external-login.module.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ConfirmEmailComponent } from './email-confirmation/confirm-email/confirm-email.component';
|
||||
import { ConfirmationSentComponent } from './email-confirmation/confirmation-sent/confirmation-sent.component';
|
||||
import { ProvideEmailComponent } from './email-confirmation/provide-email/provide-email.component';
|
||||
import { ExternalLogInComponent } from './external-log-in/external-log-in.component';
|
||||
import { OrcidConfirmationComponent } from './registration-types/orcid-confirmation/orcid-confirmation.component';
|
||||
import { SharedModule } from '../shared/shared.module';
|
||||
|
||||
const COMPONENTS = [
|
||||
ExternalLogInComponent,
|
||||
ProvideEmailComponent,
|
||||
ConfirmEmailComponent,
|
||||
ConfirmationSentComponent,
|
||||
];
|
||||
|
||||
const ENTRY_COMPONENTS = [OrcidConfirmationComponent];
|
||||
|
||||
@NgModule({
|
||||
declarations: [...COMPONENTS, ...ENTRY_COMPONENTS],
|
||||
imports: [CommonModule, SharedModule],
|
||||
exports: [...COMPONENTS, ...ENTRY_COMPONENTS],
|
||||
})
|
||||
export class ExternalLoginModule {
|
||||
/**
|
||||
* NOTE: this method allows to resolve issue with components that using a custom decorator
|
||||
* which are not loaded during SSR otherwise
|
||||
*/
|
||||
static withEntryComponents() {
|
||||
return {
|
||||
ngModule: ExternalLoginModule,
|
||||
providers: ENTRY_COMPONENTS.map((component) => ({ provide: component })),
|
||||
};
|
||||
}
|
||||
}
|
@@ -0,0 +1,79 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { RegistrationTokenGuard } from './registration-token.guard';
|
||||
import { ActivatedRoute, convertToParamMap, Params, Router } from '@angular/router';
|
||||
import { of as observableOf } from 'rxjs';
|
||||
import { RouterMock } from '../../shared/mocks/router.mock';
|
||||
import { Registration } from '../../core/shared/registration.model';
|
||||
import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
|
||||
import { EPerson } from '../../core/eperson/models/eperson.model';
|
||||
import { EpersonRegistrationService } from '../../core/data/eperson-registration.service';
|
||||
import { AuthService } from '../../core/auth/auth.service';
|
||||
|
||||
describe('RegistrationTokenGuard', () => {
|
||||
let guard: RegistrationTokenGuard;
|
||||
const route = new RouterMock();
|
||||
const registrationWithGroups = Object.assign(new Registration(),
|
||||
{
|
||||
email: 'test@email.org',
|
||||
token: 'test-token',
|
||||
});
|
||||
const epersonRegistrationService = jasmine.createSpyObj('epersonRegistrationService', {
|
||||
searchByTokenAndHandleError: createSuccessfulRemoteDataObject$(registrationWithGroups)
|
||||
});
|
||||
const authService = {
|
||||
getAuthenticatedUserFromStore: () => observableOf(ePerson),
|
||||
setRedirectUrl: () => {
|
||||
return true;
|
||||
}
|
||||
} as any;
|
||||
const ePerson = Object.assign(new EPerson(), {
|
||||
id: 'test-eperson',
|
||||
uuid: 'test-eperson'
|
||||
});
|
||||
beforeEach(() => {
|
||||
const paramObject: Params = {};
|
||||
paramObject.token = '1234';
|
||||
TestBed.configureTestingModule({
|
||||
providers: [{provide: Router, useValue: route},
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: {
|
||||
queryParamMap: observableOf(convertToParamMap(paramObject))
|
||||
},
|
||||
},
|
||||
{provide: EpersonRegistrationService, useValue: epersonRegistrationService},
|
||||
{provide: AuthService, useValue: authService}
|
||||
]
|
||||
});
|
||||
guard = TestBed.get(RegistrationTokenGuard);
|
||||
});
|
||||
|
||||
it('should be created', () => {
|
||||
expect(guard).toBeTruthy();
|
||||
});
|
||||
describe('based on the response of "searchByToken have', () => {
|
||||
it('can activate must return true when registration data includes groups', () => {
|
||||
(guard.canActivate({ params: { token: '123456789' } } as any, {} as any) as any)
|
||||
.subscribe(
|
||||
(canActivate) => {
|
||||
expect(canActivate).toEqual(true);
|
||||
}
|
||||
);
|
||||
});
|
||||
it('can activate must return false when registration data includes groups', () => {
|
||||
const registrationWithDifferentUsedFromLoggedInt = Object.assign(new Registration(),
|
||||
{
|
||||
email: 't1@email.org',
|
||||
token: 'test-token',
|
||||
});
|
||||
epersonRegistrationService.searchByTokenAndHandleError.and.returnValue(observableOf(registrationWithDifferentUsedFromLoggedInt));
|
||||
(guard.canActivate({ params: { token: '123456789' } } as any, {} as any) as any)
|
||||
.subscribe(
|
||||
(canActivate) => {
|
||||
expect(canActivate).toEqual(false);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
});
|
||||
});
|
49
src/app/external-log-in/guards/registration-token.guard.ts
Normal file
49
src/app/external-log-in/guards/registration-token.guard.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot, } from '@angular/router';
|
||||
import { map, Observable, of } from 'rxjs';
|
||||
import { EpersonRegistrationService } from '../../core/data/eperson-registration.service';
|
||||
import { getFirstCompletedRemoteData } from '../../core/shared/operators';
|
||||
import { Registration } from '../../core/shared/registration.model';
|
||||
import { RemoteData } from '../../core/data/remote-data';
|
||||
import { hasValue } from '../../shared/empty.util';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class RegistrationTokenGuard implements CanActivate {
|
||||
constructor(
|
||||
private router: Router,
|
||||
private epersonRegistrationService: EpersonRegistrationService
|
||||
) { }
|
||||
|
||||
/**
|
||||
* Determines if a user can activate a route based on the registration token.
|
||||
* @param route - The activated route snapshot.
|
||||
* @param state - The router state snapshot.
|
||||
* @returns A value indicating if the user can activate the route.
|
||||
*/
|
||||
canActivate(
|
||||
route: ActivatedRouteSnapshot,
|
||||
state: RouterStateSnapshot
|
||||
): Observable<boolean> {
|
||||
if (route.params.token) {
|
||||
return this.epersonRegistrationService
|
||||
.searchByTokenAndHandleError(route.params.token)
|
||||
.pipe(
|
||||
getFirstCompletedRemoteData(),
|
||||
map(
|
||||
(data: RemoteData<Registration>) => {
|
||||
if (data.hasSucceeded && hasValue(data)) {
|
||||
return true;
|
||||
} else {
|
||||
this.router.navigate(['/404']);
|
||||
}
|
||||
}
|
||||
)
|
||||
);
|
||||
} else {
|
||||
this.router.navigate(['/404']);
|
||||
return of(false);
|
||||
}
|
||||
}
|
||||
}
|
@@ -0,0 +1,45 @@
|
||||
import { Registration } from '../../core/shared/registration.model';
|
||||
import { AuthMethodType } from '../../core/auth/models/auth.method-type';
|
||||
import { MetadataValue } from '../../core/shared/metadata.models';
|
||||
|
||||
export const mockRegistrationDataModel: Registration = Object.assign(
|
||||
new Registration(),
|
||||
{
|
||||
id: '3',
|
||||
email: 'user@institution.edu',
|
||||
user: '028dcbb8-0da2-4122-a0ea-254be49ca107',
|
||||
registrationType: AuthMethodType.Orcid,
|
||||
netId: '0000-1111-2222-3333',
|
||||
registrationMetadata: {
|
||||
'eperson.firstname': [
|
||||
Object.assign(new MetadataValue(), {
|
||||
value: 'User',
|
||||
language: null,
|
||||
authority: '',
|
||||
confidence: -1,
|
||||
place: -1,
|
||||
overrides: 'User',
|
||||
}),
|
||||
],
|
||||
'eperson.lastname': [
|
||||
Object.assign(new MetadataValue(), {
|
||||
value: 'Power',
|
||||
language: null,
|
||||
authority: '',
|
||||
confidence: -1,
|
||||
place: -1,
|
||||
}),
|
||||
],
|
||||
'email': [
|
||||
{
|
||||
value: 'power-user@orcid.org',
|
||||
language: null,
|
||||
authority: '',
|
||||
confidence: -1,
|
||||
place: -1,
|
||||
overrides: 'power-user@dspace.org',
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
);
|
@@ -0,0 +1,35 @@
|
||||
<form class="form-login"
|
||||
[formGroup]="form" novalidate>
|
||||
<label class="font-weight-bold mb-0 text-uppercase">{{ registratioData.registrationType }}</label>
|
||||
<input [attr.aria-label]="'netId' | translate"
|
||||
autocomplete="off"
|
||||
autofocus
|
||||
class="form-control form-control-lg position-relative mb-2"
|
||||
formControlName="netId"
|
||||
placeholder="xxxx-xxxx-xxxx-xxxx"
|
||||
type="text"
|
||||
[attr.data-test]="'netId' | dsBrowserOnly">
|
||||
<label class="font-weight-bold mb-0">{{"Last name" | translate}}</label>
|
||||
<input [attr.aria-label]="'Last name' | translate"
|
||||
autocomplete="off"
|
||||
class="form-control form-control-lg position-relative mb-2"
|
||||
formControlName="lastname"
|
||||
type="text"
|
||||
[attr.data-test]="'lastname' | dsBrowserOnly">
|
||||
<label class="font-weight-bold mb-0">{{"First name" | translate}}</label>
|
||||
<input [attr.aria-label]="'First name' | translate"
|
||||
autocomplete="off"
|
||||
class="form-control form-control-lg position-relative mb-2"
|
||||
formControlName="firstname"
|
||||
type="text"
|
||||
[attr.data-test]="'firstname' | dsBrowserOnly">
|
||||
<ng-container *ngIf="registratioData?.email">
|
||||
<label class="font-weight-bold mb-0">{{"Email" | translate}}</label>
|
||||
<input [attr.aria-label]="'Email' | translate"
|
||||
autocomplete="off"
|
||||
class="form-control form-control-lg position-relative"
|
||||
formControlName="email"
|
||||
type="email"
|
||||
[attr.data-test]="'email' | dsBrowserOnly">
|
||||
</ng-container>
|
||||
</form>
|
@@ -0,0 +1,69 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { OrcidConfirmationComponent } from './orcid-confirmation.component';
|
||||
import { FormBuilder, FormGroup } from '@angular/forms';
|
||||
import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
|
||||
import { NO_ERRORS_SCHEMA } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Registration } from 'src/app/core/shared/registration.model';
|
||||
import { mockRegistrationDataModel } from '../../models/registration-data.mock.model';
|
||||
import { BrowserOnlyMockPipe } from '../../../shared/testing/browser-only-mock.pipe';
|
||||
import { TranslateLoaderMock } from '../../../shared/mocks/translate-loader.mock';
|
||||
|
||||
describe('OrcidConfirmationComponent', () => {
|
||||
let component: OrcidConfirmationComponent;
|
||||
let fixture: ComponentFixture<OrcidConfirmationComponent>;
|
||||
let model: Registration;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
declarations: [
|
||||
OrcidConfirmationComponent,
|
||||
BrowserOnlyMockPipe,
|
||||
],
|
||||
providers: [
|
||||
FormBuilder,
|
||||
{ provide: 'registrationDataProvider', useValue: mockRegistrationDataModel },
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
TranslateModule.forRoot({
|
||||
loader: {
|
||||
provide: TranslateLoader,
|
||||
useClass: TranslateLoaderMock
|
||||
}
|
||||
}),
|
||||
],
|
||||
schemas: [NO_ERRORS_SCHEMA]
|
||||
})
|
||||
.compileComponents();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(OrcidConfirmationComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should initialize the form with disabled fields', () => {
|
||||
expect(component.form).toBeInstanceOf(FormGroup);
|
||||
expect(component.form.controls.netId.disabled).toBeTrue();
|
||||
expect(component.form.controls.firstname.disabled).toBeTrue();
|
||||
expect(component.form.controls.lastname.disabled).toBeTrue();
|
||||
expect(component.form.controls.email.disabled).toBeTrue();
|
||||
});
|
||||
|
||||
|
||||
it('should initialize the form with null email as an empty string', () => {
|
||||
component.registratioData.email = null;
|
||||
component.ngOnInit();
|
||||
fixture.detectChanges();
|
||||
const emailFormControl = component.form.get('email');
|
||||
expect(emailFormControl.value).toBe('');
|
||||
});
|
||||
|
||||
});
|
@@ -0,0 +1,60 @@
|
||||
import { ChangeDetectionStrategy, Component, Inject, OnInit } from '@angular/core';
|
||||
import { FormBuilder, FormGroup } from '@angular/forms';
|
||||
import { AuthRegistrationType } from '../../../core/auth/models/auth.registration-type';
|
||||
import { Registration } from '../../../core/shared/registration.model';
|
||||
import { renderExternalLoginConfirmationFor } from '../../decorators/external-log-in.methods-decorator';
|
||||
import { ExternalLoginMethodEntryComponent } from '../../decorators/external-login-method-entry.component';
|
||||
|
||||
@Component({
|
||||
selector: 'ds-orcid-confirmation',
|
||||
templateUrl: './orcid-confirmation.component.html',
|
||||
styleUrls: ['./orcid-confirmation.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
@renderExternalLoginConfirmationFor(AuthRegistrationType.Orcid)
|
||||
export class OrcidConfirmationComponent extends ExternalLoginMethodEntryComponent implements OnInit {
|
||||
|
||||
/**
|
||||
* The form containing the user's data
|
||||
*/
|
||||
public form: FormGroup;
|
||||
|
||||
/**
|
||||
* @param injectedRegistrationDataObject Registration object provided
|
||||
* @param formBuilder To build the form
|
||||
*/
|
||||
constructor(
|
||||
@Inject('registrationDataProvider') protected injectedRegistrationDataObject: Registration,
|
||||
private formBuilder: FormBuilder
|
||||
) {
|
||||
super(injectedRegistrationDataObject);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the form with disabled fields
|
||||
*/
|
||||
ngOnInit(): void {
|
||||
this.form = this.formBuilder.group({
|
||||
netId: [{ value: this.registratioData.netId, disabled: true }],
|
||||
firstname: [{ value: this.getFirstname(), disabled: true }],
|
||||
lastname: [{ value: this.getLastname(), disabled: true }],
|
||||
email: [{ value: this.registratioData?.email || '', disabled: true }], // email can be null
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the firstname of the user from the registration metadata
|
||||
* @returns the firstname of the user
|
||||
*/
|
||||
private getFirstname(): string {
|
||||
return this.registratioData.registrationMetadata?.['eperson.firstname']?.[0]?.value || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the lastname of the user from the registration metadata
|
||||
* @returns the lastname of the user
|
||||
*/
|
||||
private getLastname(): string {
|
||||
return this.registratioData.registrationMetadata?.['eperson.lastname']?.[0]?.value || '';
|
||||
}
|
||||
}
|
@@ -0,0 +1,46 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { RegistrationDataResolver } from './registration-data.resolver';
|
||||
import { ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router';
|
||||
import { EpersonRegistrationService } from '../../core/data/eperson-registration.service';
|
||||
import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
|
||||
import { Registration } from '../../core/shared/registration.model';
|
||||
|
||||
describe('RegistrationDataResolver', () => {
|
||||
let resolver: RegistrationDataResolver;
|
||||
let epersonRegistrationServiceSpy: jasmine.SpyObj<EpersonRegistrationService>;
|
||||
const registrationMock = Object.assign(new Registration(), {
|
||||
email: 'test@user.com',
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
const spy = jasmine.createSpyObj('EpersonRegistrationService', ['searchByTokenAndHandleError']);
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
RegistrationDataResolver,
|
||||
{ provide: EpersonRegistrationService, useValue: spy }
|
||||
]
|
||||
});
|
||||
resolver = TestBed.inject(RegistrationDataResolver);
|
||||
epersonRegistrationServiceSpy = TestBed.inject(EpersonRegistrationService) as jasmine.SpyObj<EpersonRegistrationService>;
|
||||
});
|
||||
|
||||
it('should be created', () => {
|
||||
expect(resolver).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should resolve registration data based on a token', () => {
|
||||
const token = 'abc123';
|
||||
const registrationRD$ = createSuccessfulRemoteDataObject$(registrationMock);
|
||||
epersonRegistrationServiceSpy.searchByTokenAndHandleError.and.returnValue(registrationRD$);
|
||||
const route = new ActivatedRouteSnapshot();
|
||||
route.params = { token: token };
|
||||
const state = {} as RouterStateSnapshot;
|
||||
|
||||
resolver.resolve(route, state).subscribe((data) => {
|
||||
expect(data).toEqual(createSuccessfulRemoteDataObject(registrationMock));
|
||||
});
|
||||
expect(epersonRegistrationServiceSpy.searchByTokenAndHandleError).toHaveBeenCalledWith(token);
|
||||
});
|
||||
});
|
@@ -0,0 +1,38 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { ActivatedRouteSnapshot, Resolve, RouterStateSnapshot, } from '@angular/router';
|
||||
import { Observable } from 'rxjs';
|
||||
import { hasValue } from '../../shared/empty.util';
|
||||
import { getFirstCompletedRemoteData } from '../../core/shared/operators';
|
||||
import { Registration } from '../../core/shared/registration.model';
|
||||
import { RemoteData } from '../../core/data/remote-data';
|
||||
import { EpersonRegistrationService } from '../../core/data/eperson-registration.service';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
/**
|
||||
* Resolver for retrieving registration data based on a token.
|
||||
*/
|
||||
export class RegistrationDataResolver implements Resolve<RemoteData<Registration>> {
|
||||
|
||||
/**
|
||||
* Constructor for RegistrationDataResolver.
|
||||
* @param epersonRegistrationService The EpersonRegistrationService used to retrieve registration data.
|
||||
*/
|
||||
constructor(private epersonRegistrationService: EpersonRegistrationService) {}
|
||||
|
||||
/**
|
||||
* Resolves registration data based on a token.
|
||||
* @param route The ActivatedRouteSnapshot containing the token parameter.
|
||||
* @param state The RouterStateSnapshot.
|
||||
* @returns An Observable of Registration.
|
||||
*/
|
||||
resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<RemoteData<Registration>> {
|
||||
const token = route.params.token;
|
||||
if (hasValue(token)) {
|
||||
return this.epersonRegistrationService.searchByTokenAndHandleError(token).pipe(
|
||||
getFirstCompletedRemoteData(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
@@ -0,0 +1,78 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { ExternalLoginService } from './external-login.service';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { of as observableOf } from 'rxjs';
|
||||
import { NO_ERRORS_SCHEMA } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { EpersonRegistrationService } from '../../core/data/eperson-registration.service';
|
||||
import { NotificationsService } from '../../shared/notifications/notifications.service';
|
||||
import { RemoteData } from '../../core/data/remote-data';
|
||||
import { Registration } from '../../core/shared/registration.model';
|
||||
import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub';
|
||||
import { RouterMock } from '../../shared/mocks/router.mock';
|
||||
import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
|
||||
|
||||
describe('ExternalLoginService', () => {
|
||||
let service: ExternalLoginService;
|
||||
let epersonRegistrationService;
|
||||
let router: any;
|
||||
let notificationService;
|
||||
let translate;
|
||||
|
||||
const values = ['value1', 'value2'];
|
||||
const field = 'field1';
|
||||
const registrationId = 'registrationId1';
|
||||
const token = 'token1';
|
||||
const operation = 'add';
|
||||
|
||||
beforeEach(() => {
|
||||
epersonRegistrationService = jasmine.createSpyObj('epersonRegistrationService', {
|
||||
patchUpdateRegistration: createSuccessfulRemoteDataObject$(new Registration)
|
||||
});
|
||||
router = new RouterMock();
|
||||
notificationService = new NotificationsServiceStub();
|
||||
translate = jasmine.createSpyObj('TranslateService', ['get']);
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
ExternalLoginService,
|
||||
{ provide: EpersonRegistrationService, useValue: epersonRegistrationService },
|
||||
{ provide: Router, useValue: router },
|
||||
{ provide: NotificationsService, useValue: notificationService },
|
||||
{ provide: TranslateService, useValue: translate },
|
||||
{ provide: Store, useValue: {} },
|
||||
],
|
||||
schemas: [NO_ERRORS_SCHEMA]
|
||||
});
|
||||
service = TestBed.inject(ExternalLoginService);
|
||||
});
|
||||
|
||||
it('should be created', () => {
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should call epersonRegistrationService.patchUpdateRegistration with the correct parameters', () => {
|
||||
epersonRegistrationService.patchUpdateRegistration.and.returnValue(observableOf({} as RemoteData<Registration>));
|
||||
service.patchUpdateRegistration(values, field, registrationId, token, operation);
|
||||
expect(epersonRegistrationService.patchUpdateRegistration).toHaveBeenCalledWith(values, field, registrationId, token, operation);
|
||||
});
|
||||
|
||||
it('should navigate to /email-confirmation if the remote data has succeeded', () => {
|
||||
const remoteData = { hasSucceeded: true } as RemoteData<Registration>;
|
||||
epersonRegistrationService.patchUpdateRegistration.and.returnValue(observableOf(remoteData));
|
||||
service.patchUpdateRegistration(values, field, registrationId, token, operation).subscribe(() => {
|
||||
expect((router as any).navigate).toHaveBeenCalledWith(['/email-confirmation']);
|
||||
});
|
||||
});
|
||||
|
||||
it('should show an error notification if the remote data has failed', () => {
|
||||
const remoteData = { hasFailed: true } as RemoteData<Registration>;
|
||||
epersonRegistrationService.patchUpdateRegistration.and.returnValue(observableOf(remoteData));
|
||||
translate.get.and.returnValue(observableOf('error message'));
|
||||
service.patchUpdateRegistration(values, field, registrationId, token, operation).subscribe(() => {
|
||||
expect(notificationService.error).toHaveBeenCalledWith('error message');
|
||||
});
|
||||
});
|
||||
});
|
65
src/app/external-log-in/services/external-login.service.ts
Normal file
65
src/app/external-log-in/services/external-login.service.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { filter, map, Observable } from 'rxjs';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { AuthMethod } from 'src/app/core/auth/models/auth.method';
|
||||
import { getAuthenticationMethods } from 'src/app/core/auth/selectors';
|
||||
import { select, Store } from '@ngrx/store';
|
||||
import { CoreState } from 'src/app/core/core-state.model';
|
||||
import { getFirstCompletedRemoteData } from '../../core/shared/operators';
|
||||
import { RemoteData } from '../../core/data/remote-data';
|
||||
import { NoContent } from '../../core/shared/NoContent.model';
|
||||
import { EpersonRegistrationService } from '../../core/data/eperson-registration.service';
|
||||
import { NotificationsService } from '../../shared/notifications/notifications.service';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class ExternalLoginService {
|
||||
|
||||
constructor(
|
||||
private epersonRegistrationService: EpersonRegistrationService,
|
||||
private router: Router,
|
||||
private notificationService: NotificationsService,
|
||||
private translate: TranslateService,
|
||||
private store: Store<CoreState>,
|
||||
) { }
|
||||
|
||||
/**
|
||||
* Update the registration data.
|
||||
* Send a patch request to the server to update the registration data.
|
||||
* @param values the values to update or add
|
||||
* @param field the filed to be updated
|
||||
* @param registrationId the registration id
|
||||
* @param token the registration token
|
||||
* @param operation operation to be performed
|
||||
*/
|
||||
patchUpdateRegistration(values: string[], field: string, registrationId: string, token: string, operation: 'add' | 'replace'): Observable<RemoteData<NoContent>> {
|
||||
const updatedValues = values.map((value) => value);
|
||||
return this.epersonRegistrationService.patchUpdateRegistration(updatedValues, field, registrationId, token, operation).pipe(
|
||||
getFirstCompletedRemoteData(),
|
||||
map((rd) => {
|
||||
if (rd.hasSucceeded) {
|
||||
this.router.navigate(['/email-confirmation']);
|
||||
}
|
||||
if (rd.hasFailed) {
|
||||
this.notificationService.error(this.translate.get('external-login-page.provide-email.notifications.error'));
|
||||
}
|
||||
return rd;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an Observable that emits the external authentication location for the given registration type.
|
||||
* @param registrationType The type of registration to get the external authentication location for.
|
||||
* @returns An Observable that emits the external authentication location as a string.
|
||||
*/
|
||||
getExternalAuthLocation(registrationType: string): Observable<string> {
|
||||
return this.store.pipe(
|
||||
select(getAuthenticationMethods),
|
||||
filter((methods: AuthMethod[]) => methods.length > 0),
|
||||
map((methods: AuthMethod[]) => methods.find(m => m.authMethodType === registrationType.toLocaleLowerCase()).location),
|
||||
);
|
||||
}
|
||||
}
|
@@ -0,0 +1,10 @@
|
||||
import { Routes } from '@angular/router';
|
||||
import { ExternalLoginEmailConfirmationPageComponent } from './external-login-email-confirmation-page.component';
|
||||
|
||||
export const ROUTES: Routes = [
|
||||
{
|
||||
path: '',
|
||||
pathMatch: 'full',
|
||||
component: ExternalLoginEmailConfirmationPageComponent,
|
||||
},
|
||||
];
|
@@ -0,0 +1,17 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { RouterModule, Routes } from '@angular/router';
|
||||
import { ExternalLoginEmailConfirmationPageComponent } from './external-login-email-confirmation-page.component';
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
pathMatch: 'full',
|
||||
component: ExternalLoginEmailConfirmationPageComponent,
|
||||
},
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
imports: [RouterModule.forChild(routes)],
|
||||
exports: [RouterModule],
|
||||
})
|
||||
export class ExternalLoginEmailConfirmationPageRoutingModule {}
|
@@ -0,0 +1,3 @@
|
||||
<div class="container">
|
||||
<ds-confirmation-sent></ds-confirmation-sent>
|
||||
</div>
|
@@ -0,0 +1,45 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { ExternalLoginEmailConfirmationPageComponent } from './external-login-email-confirmation-page.component';
|
||||
import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
|
||||
import { TranslateLoaderMock } from '../shared/mocks/translate-loader.mock';
|
||||
import {
|
||||
ConfirmationSentComponent
|
||||
} from '../external-log-in/email-confirmation/confirmation-sent/confirmation-sent.component';
|
||||
|
||||
describe('ExternalLoginEmailConfirmationPageComponent', () => {
|
||||
let component: ExternalLoginEmailConfirmationPageComponent;
|
||||
let fixture: ComponentFixture<ExternalLoginEmailConfirmationPageComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
declarations: [
|
||||
ExternalLoginEmailConfirmationPageComponent,
|
||||
ConfirmationSentComponent ],
|
||||
imports: [
|
||||
TranslateModule.forRoot({
|
||||
loader: {
|
||||
provide: TranslateLoader,
|
||||
useClass: TranslateLoaderMock,
|
||||
},
|
||||
}),
|
||||
]
|
||||
})
|
||||
.compileComponents();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(ExternalLoginEmailConfirmationPageComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should render ConfirmationSentComponent', () => {
|
||||
const compiled = fixture.nativeElement;
|
||||
expect(compiled.querySelector('ds-confirmation-sent')).toBeTruthy();
|
||||
});
|
||||
});
|
@@ -0,0 +1,8 @@
|
||||
import { Component } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
templateUrl: './external-login-email-confirmation-page.component.html',
|
||||
styleUrls: ['./external-login-email-confirmation-page.component.scss']
|
||||
})
|
||||
export class ExternalLoginEmailConfirmationPageComponent {
|
||||
}
|
@@ -0,0 +1,21 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
import {
|
||||
ExternalLoginEmailConfirmationPageRoutingModule
|
||||
} from './external-login-email-confirmation-page-routing.module';
|
||||
import { ExternalLoginEmailConfirmationPageComponent } from './external-login-email-confirmation-page.component';
|
||||
import { ExternalLoginModule } from '../external-log-in/external-login.module';
|
||||
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
ExternalLoginEmailConfirmationPageComponent
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
ExternalLoginEmailConfirmationPageRoutingModule,
|
||||
ExternalLoginModule,
|
||||
]
|
||||
})
|
||||
export class ExternalLoginEmailConfirmationPageModule { }
|
@@ -0,0 +1,22 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { RouterModule, Routes } from '@angular/router';
|
||||
import { ThemedExternalLoginPageComponent } from './themed-external-login-page.component';
|
||||
import { RegistrationDataResolver } from '../external-log-in/resolvers/registration-data.resolver';
|
||||
import { RegistrationTokenGuard } from '../external-log-in/guards/registration-token.guard';
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
pathMatch: 'full',
|
||||
component: ThemedExternalLoginPageComponent,
|
||||
canActivate: [RegistrationTokenGuard],
|
||||
resolve: { registrationData: RegistrationDataResolver },
|
||||
},
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
imports: [RouterModule.forChild(routes)],
|
||||
exports: [RouterModule],
|
||||
providers: [],
|
||||
})
|
||||
export class ExternalLoginPageRoutingModule {}
|
@@ -0,0 +1,11 @@
|
||||
<div class="container">
|
||||
<ng-container *ngIf="(registrationData$ | async)">
|
||||
<ds-external-log-in [registrationData]="registrationData$ | async" [token]="token"></ds-external-log-in>
|
||||
</ng-container>
|
||||
|
||||
<ds-alert
|
||||
*ngIf="hasErrors"
|
||||
[type]="AlertTypeEnum.Error"
|
||||
[content]="'external-login.error.notification' | translate"
|
||||
></ds-alert>
|
||||
</div>
|
@@ -0,0 +1,79 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { ExternalLoginPageComponent } from './external-login-page.component';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { of } from 'rxjs';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
|
||||
import { TranslateLoaderMock } from '../shared/mocks/translate-loader.mock';
|
||||
import { Registration } from '../core/shared/registration.model';
|
||||
|
||||
describe('ExternalLoginPageComponent', () => {
|
||||
let component: ExternalLoginPageComponent;
|
||||
let fixture: ComponentFixture<ExternalLoginPageComponent>;
|
||||
|
||||
const registrationDataMock = {
|
||||
registrationType: 'orcid',
|
||||
email: 'test@test.com',
|
||||
netId: '0000-0000-0000-0000',
|
||||
user: 'a44d8c9e-9b1f-4e7f-9b1a-5c9d8a0b1f1a',
|
||||
registrationMetadata: {
|
||||
'email': [{ value: 'test@test.com' }],
|
||||
'eperson.lastname': [{ value: 'Doe' }],
|
||||
'eperson.firstname': [{ value: 'John' }],
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
declarations: [ ExternalLoginPageComponent ],
|
||||
providers: [
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: {
|
||||
snapshot: {
|
||||
params: {
|
||||
token: '1234567890',
|
||||
},
|
||||
},
|
||||
data: of(registrationDataMock),
|
||||
},
|
||||
},
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
TranslateModule.forRoot({
|
||||
loader: {
|
||||
provide: TranslateLoader,
|
||||
useClass: TranslateLoaderMock,
|
||||
},
|
||||
}),
|
||||
],
|
||||
})
|
||||
.compileComponents();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(ExternalLoginPageComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should set the token from the query params', () => {
|
||||
expect(component.token).toEqual('1234567890');
|
||||
});
|
||||
|
||||
it('should display the DsExternalLogIn component when there are no errors', () => {
|
||||
const registrationData = Object.assign(new Registration(), registrationDataMock);
|
||||
component.registrationData$ = of(registrationData);
|
||||
component.token = '1234567890';
|
||||
component.hasErrors = false;
|
||||
fixture.detectChanges();
|
||||
const dsExternalLogInComponent = fixture.nativeElement.querySelector('ds-external-log-in');
|
||||
expect(dsExternalLogInComponent).toBeTruthy();
|
||||
});
|
||||
});
|
48
src/app/external-login-page/external-login-page.component.ts
Normal file
48
src/app/external-login-page/external-login-page.component.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { hasNoValue } from '../shared/empty.util';
|
||||
import { first, map, Observable, tap } from 'rxjs';
|
||||
import { Registration } from '../core/shared/registration.model';
|
||||
import { RemoteData } from '../core/data/remote-data';
|
||||
import { AlertType } from '../shared/alert/alert-type';
|
||||
|
||||
@Component({
|
||||
templateUrl: './external-login-page.component.html',
|
||||
styleUrls: ['./external-login-page.component.scss'],
|
||||
})
|
||||
export class ExternalLoginPageComponent implements OnInit {
|
||||
/**
|
||||
* The token used to get the registration data,
|
||||
* retrieved from the url.
|
||||
* @memberof ExternalLoginPageComponent
|
||||
*/
|
||||
public token: string;
|
||||
/**
|
||||
* The registration data of the user.
|
||||
*/
|
||||
public registrationData$: Observable<Registration>;
|
||||
/**
|
||||
* The type of alert to show.
|
||||
*/
|
||||
public AlertTypeEnum = AlertType;
|
||||
/**
|
||||
* Whether the component has errors.
|
||||
*/
|
||||
public hasErrors = false;
|
||||
|
||||
constructor(
|
||||
private arouter: ActivatedRoute
|
||||
) {
|
||||
this.token = this.arouter.snapshot.params.token;
|
||||
this.hasErrors = hasNoValue(this.arouter.snapshot.params.token);
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.registrationData$ =
|
||||
this.arouter.data.pipe(
|
||||
first(),
|
||||
tap((data) => this.hasErrors = (data.registrationData as RemoteData<Registration>).hasFailed),
|
||||
map((data) => (data.registrationData as RemoteData<Registration>).payload)
|
||||
);
|
||||
}
|
||||
}
|
26
src/app/external-login-page/external-login-page.module.ts
Normal file
26
src/app/external-login-page/external-login-page.module.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
import { ExternalLoginPageRoutingModule } from './external-login-page-routing.module';
|
||||
import { ExternalLoginPageComponent } from './external-login-page.component';
|
||||
import { ThemedExternalLoginPageComponent } from './themed-external-login-page.component';
|
||||
import { SharedModule } from '../shared/shared.module';
|
||||
import { ExternalLoginModule } from '../external-log-in/external-login.module';
|
||||
|
||||
const COMPONENTS = [
|
||||
ExternalLoginPageComponent,
|
||||
ThemedExternalLoginPageComponent,
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
...COMPONENTS
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
ExternalLoginPageRoutingModule,
|
||||
SharedModule,
|
||||
ExternalLoginModule
|
||||
]
|
||||
})
|
||||
export class ExternalLoginPageModule { }
|
14
src/app/external-login-page/external-login-routes.ts
Normal file
14
src/app/external-login-page/external-login-routes.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Route } from '@angular/router';
|
||||
import { ThemedExternalLoginPageComponent } from './themed-external-login-page.component';
|
||||
import { RegistrationTokenGuard } from '../external-log-in/guards/registration-token.guard';
|
||||
import { RegistrationDataResolver } from '../external-log-in/resolvers/registration-data.resolver';
|
||||
|
||||
export const ROUTES: Route[] = [
|
||||
{
|
||||
path: '',
|
||||
pathMatch: 'full',
|
||||
component: ThemedExternalLoginPageComponent,
|
||||
canActivate: [RegistrationTokenGuard],
|
||||
resolve: { registrationData: RegistrationDataResolver },
|
||||
},
|
||||
];
|
@@ -0,0 +1,25 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { ThemedComponent } from '../shared/theme-support/themed.component';
|
||||
import { ExternalLoginPageComponent } from './external-login-page.component';
|
||||
|
||||
/**
|
||||
* Themed wrapper for ExternalLoginPageComponent
|
||||
*/
|
||||
@Component({
|
||||
selector: 'ds-themed-external-login-page',
|
||||
styleUrls: [],
|
||||
templateUrl: './../shared/theme-support/themed.component.html'
|
||||
})
|
||||
export class ThemedExternalLoginPageComponent extends ThemedComponent<ExternalLoginPageComponent> {
|
||||
protected getComponentName(): string {
|
||||
return 'ExternalLoginPageComponent';
|
||||
}
|
||||
|
||||
protected importThemedComponent(themeName: string): Promise<any> {
|
||||
return import(`../../themes/${themeName}/app/external-login-page/external-login-page.component`);
|
||||
}
|
||||
|
||||
protected importUnthemedComponent(): Promise<any> {
|
||||
return import(`./external-login-page.component`);
|
||||
}
|
||||
}
|
@@ -0,0 +1,14 @@
|
||||
import { ExternalLoginReviewAccountInfoPageComponent } from './external-login-review-account-info-page.component';
|
||||
import { ReviewAccountGuard } from './helpers/review-account.guard';
|
||||
import { RegistrationDataResolver } from '../external-log-in/resolvers/registration-data.resolver';
|
||||
import { Route } from '@angular/router';
|
||||
|
||||
export const ROUTES: Route[] = [
|
||||
{
|
||||
path: '',
|
||||
pathMatch: 'full',
|
||||
component: ExternalLoginReviewAccountInfoPageComponent,
|
||||
canActivate: [ReviewAccountGuard],
|
||||
resolve: { registrationData: RegistrationDataResolver },
|
||||
},
|
||||
]
|
@@ -0,0 +1,21 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { RouterModule, Routes } from '@angular/router';
|
||||
import { ExternalLoginReviewAccountInfoPageComponent } from './external-login-review-account-info-page.component';
|
||||
import { ReviewAccountGuard } from './helpers/review-account.guard';
|
||||
import { RegistrationDataResolver } from '../external-log-in/resolvers/registration-data.resolver';
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
pathMatch: 'full',
|
||||
component: ExternalLoginReviewAccountInfoPageComponent,
|
||||
canActivate: [ReviewAccountGuard],
|
||||
resolve: { registrationData: RegistrationDataResolver },
|
||||
},
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
imports: [RouterModule.forChild(routes)],
|
||||
exports: [RouterModule],
|
||||
})
|
||||
export class ExternalLoginReviewAccountInfoRoutingModule {}
|
@@ -0,0 +1,13 @@
|
||||
<div class="container">
|
||||
<ng-container *ngIf="(registrationData$ | async)">
|
||||
<ds-review-account-info
|
||||
[registrationToken]="token"
|
||||
[registrationData]="registrationData$ | async"
|
||||
></ds-review-account-info>
|
||||
</ng-container>
|
||||
<ds-alert
|
||||
*ngIf="hasErrors"
|
||||
[type]="AlertTypeEnum.Error"
|
||||
[content]="'review-account-info.alert.error.content'"
|
||||
></ds-alert>
|
||||
</div>
|
@@ -0,0 +1,60 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { of } from 'rxjs';
|
||||
import { ExternalLoginReviewAccountInfoPageComponent } from './external-login-review-account-info-page.component';
|
||||
import { mockRegistrationDataModel } from '../external-log-in/models/registration-data.mock.model';
|
||||
|
||||
describe('ExternalLoginReviewAccountInfoPageComponent', () => {
|
||||
let component: ExternalLoginReviewAccountInfoPageComponent;
|
||||
let fixture: ComponentFixture<ExternalLoginReviewAccountInfoPageComponent>;
|
||||
|
||||
const mockActivatedRoute = {
|
||||
snapshot: {
|
||||
params: {
|
||||
token: '1234567890'
|
||||
}
|
||||
},
|
||||
data: of({
|
||||
registrationData: mockRegistrationDataModel
|
||||
})
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
declarations: [ExternalLoginReviewAccountInfoPageComponent],
|
||||
providers: [
|
||||
{ provide: ActivatedRoute, useValue: mockActivatedRoute }
|
||||
]
|
||||
})
|
||||
.compileComponents();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(ExternalLoginReviewAccountInfoPageComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should set the token from the query params', () => {
|
||||
expect(component.token).toEqual('1234567890');
|
||||
});
|
||||
|
||||
it('should display review account info component when there are no errors', () => {
|
||||
component.hasErrors = false;
|
||||
component.registrationData$ = of(mockRegistrationDataModel);
|
||||
fixture.detectChanges();
|
||||
const reviewAccountInfoComponent = fixture.nativeElement.querySelector('ds-review-account-info');
|
||||
expect(reviewAccountInfoComponent).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should display error alert when there are errors', () => {
|
||||
component.hasErrors = true;
|
||||
fixture.detectChanges();
|
||||
const errorAlertComponent = fixture.nativeElement.querySelector('ds-alert');
|
||||
expect(errorAlertComponent).toBeTruthy();
|
||||
});
|
||||
});
|
@@ -0,0 +1,47 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { first, map, Observable, tap } from 'rxjs';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { hasNoValue } from '../shared/empty.util';
|
||||
import { Registration } from '../core/shared/registration.model';
|
||||
import { RemoteData } from '../core/data/remote-data';
|
||||
import { AlertType } from '../shared/alert/alert-type';
|
||||
|
||||
@Component({
|
||||
templateUrl: './external-login-review-account-info-page.component.html',
|
||||
styleUrls: ['./external-login-review-account-info-page.component.scss']
|
||||
})
|
||||
export class ExternalLoginReviewAccountInfoPageComponent implements OnInit {
|
||||
/**
|
||||
* The token used to get the registration data
|
||||
*/
|
||||
public token: string;
|
||||
|
||||
/**
|
||||
* The type of alert to show
|
||||
*/
|
||||
public AlertTypeEnum = AlertType;
|
||||
|
||||
/**
|
||||
* The registration data of the user
|
||||
*/
|
||||
public registrationData$: Observable<Registration>;
|
||||
/**
|
||||
* Whether the component has errors
|
||||
*/
|
||||
public hasErrors = false;
|
||||
|
||||
constructor(
|
||||
private arouter: ActivatedRoute
|
||||
) {
|
||||
this.token = this.arouter.snapshot.params.token;
|
||||
this.hasErrors = hasNoValue(this.arouter.snapshot.params.token);
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.registrationData$ = this.arouter.data.pipe(
|
||||
first(),
|
||||
tap((data) => this.hasErrors = (data.registrationData as RemoteData<Registration>).hasFailed),
|
||||
map((data) => (data.registrationData as RemoteData<Registration>).payload));
|
||||
}
|
||||
}
|
||||
|
@@ -0,0 +1,30 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
import { ExternalLoginReviewAccountInfoRoutingModule } from './external-login-review-account-info-page-routing.module';
|
||||
import { ExternalLoginReviewAccountInfoPageComponent } from './external-login-review-account-info-page.component';
|
||||
import { CompareValuesPipe } from './helpers/compare-values.pipe';
|
||||
import {
|
||||
ThemedExternalLoginReviewAccountInfoPageComponent
|
||||
} from './themed-external-login-review-account-info-page.component';
|
||||
import { ReviewAccountInfoComponent } from './review-account-info/review-account-info.component';
|
||||
import { UiSwitchModule } from 'ngx-ui-switch';
|
||||
import { SharedModule } from '../shared/shared.module';
|
||||
import { ExternalLoginModule } from '../external-log-in/external-login.module';
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
ExternalLoginReviewAccountInfoPageComponent,
|
||||
CompareValuesPipe,
|
||||
ThemedExternalLoginReviewAccountInfoPageComponent,
|
||||
ReviewAccountInfoComponent
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
ExternalLoginReviewAccountInfoRoutingModule,
|
||||
SharedModule,
|
||||
UiSwitchModule,
|
||||
ExternalLoginModule
|
||||
]
|
||||
})
|
||||
export class ExternalLoginReviewAccountInfoModule { }
|
@@ -0,0 +1,22 @@
|
||||
import { Pipe, PipeTransform } from '@angular/core';
|
||||
|
||||
@Pipe({
|
||||
name: 'dsCompareValues'
|
||||
})
|
||||
export class CompareValuesPipe implements PipeTransform {
|
||||
|
||||
/**
|
||||
* Returns a string with a checkmark if the received value is equal to the current value,
|
||||
* or the current value if they are not equal.
|
||||
* @param receivedValue the value received from the registration data
|
||||
* @param currentValue the value from the current user
|
||||
* @returns the value to be displayed in the template
|
||||
*/
|
||||
transform(receivedValue: string, currentValue: string): string {
|
||||
if (receivedValue === currentValue) {
|
||||
return '<i class="fa fa-check-circle text-success fa-xl" aria-hidden="true"></i>';
|
||||
} else {
|
||||
return currentValue;
|
||||
}
|
||||
}
|
||||
}
|
@@ -0,0 +1,78 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { ReviewAccountGuard } from './review-account.guard';
|
||||
import { ActivatedRoute, convertToParamMap, Params, Router } from '@angular/router';
|
||||
import { of as observableOf, of } from 'rxjs';
|
||||
import { AuthService } from '../../core/auth/auth.service';
|
||||
import { EpersonRegistrationService } from '../../core/data/eperson-registration.service';
|
||||
import { RouterMock } from '../../shared/mocks/router.mock';
|
||||
import { createFailedRemoteDataObject$, createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
|
||||
import { Registration } from '../../core/shared/registration.model';
|
||||
import { AuthRegistrationType } from '../../core/auth/models/auth.registration-type';
|
||||
|
||||
describe('ReviewAccountGuard', () => {
|
||||
let guard: ReviewAccountGuard;
|
||||
let epersonRegistrationService: any;
|
||||
let authService: any;
|
||||
|
||||
const route = new RouterMock();
|
||||
const registrationMock = Object.assign(new Registration(),
|
||||
{
|
||||
email: 'test@email.org',
|
||||
registrationType: AuthRegistrationType.Validation
|
||||
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
const paramObject: Params = {};
|
||||
paramObject.token = '1234';
|
||||
epersonRegistrationService = jasmine.createSpyObj('epersonRegistrationService', {
|
||||
searchByTokenAndHandleError: createSuccessfulRemoteDataObject$(registrationMock)
|
||||
});
|
||||
authService = {
|
||||
isAuthenticated: () => observableOf(true)
|
||||
} as any;
|
||||
TestBed.configureTestingModule({
|
||||
providers: [{ provide: Router, useValue: route },
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: {
|
||||
queryParamMap: observableOf(convertToParamMap(paramObject))
|
||||
},
|
||||
},
|
||||
{ provide: EpersonRegistrationService, useValue: epersonRegistrationService },
|
||||
{ provide: AuthService, useValue: authService }
|
||||
]
|
||||
});
|
||||
guard = TestBed.inject(ReviewAccountGuard);
|
||||
});
|
||||
|
||||
it('should be created', () => {
|
||||
expect(guard).toBeTruthy();
|
||||
});
|
||||
|
||||
it('can activate must return true when registration type is validation', () => {
|
||||
(guard.canActivate({ params: { token: 'valid token' } } as any, {} as any) as any)
|
||||
.subscribe(
|
||||
(canActivate) => {
|
||||
expect(canActivate).toEqual(true);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('should navigate to 404 if the registration search fails', () => {
|
||||
epersonRegistrationService.searchByTokenAndHandleError.and.returnValue(createFailedRemoteDataObject$());
|
||||
(guard.canActivate({ params: { token: 'invalid-token' } } as any, {} as any) as any).subscribe((result) => {
|
||||
expect(result).toBeFalse();
|
||||
expect(route.navigate).toHaveBeenCalledWith(['/404']);
|
||||
});
|
||||
});
|
||||
|
||||
it('should navigate to 404 if the registration type is not validation and the user is not authenticated', () => {
|
||||
registrationMock.registrationType = AuthRegistrationType.Orcid;
|
||||
epersonRegistrationService.searchByTokenAndHandleError.and.returnValue(createSuccessfulRemoteDataObject$(registrationMock));
|
||||
spyOn(authService, 'isAuthenticated').and.returnValue(of(false));
|
||||
(guard.canActivate({ params: { token: 'invalid-token' } } as any, {} as any) as any).subscribe((result) => {
|
||||
expect(route.navigate).toHaveBeenCalledWith(['/404']);
|
||||
});
|
||||
});
|
||||
});
|
@@ -0,0 +1,65 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot, } from '@angular/router';
|
||||
import { catchError, mergeMap, Observable, of, tap } from 'rxjs';
|
||||
import { AuthService } from '../../core/auth/auth.service';
|
||||
import { AuthRegistrationType } from '../../core/auth/models/auth.registration-type';
|
||||
import { EpersonRegistrationService } from '../../core/data/eperson-registration.service';
|
||||
import { RemoteData } from '../../core/data/remote-data';
|
||||
import { getFirstCompletedRemoteData } from '../../core/shared/operators';
|
||||
import { Registration } from '../../core/shared/registration.model';
|
||||
import { hasValue } from '../../shared/empty.util';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class ReviewAccountGuard implements CanActivate {
|
||||
constructor(
|
||||
private router: Router,
|
||||
private epersonRegistrationService: EpersonRegistrationService,
|
||||
private authService: AuthService
|
||||
) { }
|
||||
|
||||
/**
|
||||
* Determines if a user can activate a route based on the registration token.
|
||||
* @param route - The activated route snapshot.
|
||||
* @param state - The router state snapshot.
|
||||
* @returns A value indicating if the user can activate the route.
|
||||
*/
|
||||
canActivate(
|
||||
route: ActivatedRouteSnapshot,
|
||||
state: RouterStateSnapshot
|
||||
): Promise<boolean> | boolean | Observable<boolean> {
|
||||
if (route.params.token) {
|
||||
return this.epersonRegistrationService
|
||||
.searchByTokenAndHandleError(route.params.token)
|
||||
.pipe(
|
||||
getFirstCompletedRemoteData(),
|
||||
mergeMap(
|
||||
(data: RemoteData<Registration>) => {
|
||||
if (data.hasSucceeded && hasValue(data.payload)) {
|
||||
// is the registration type validation (account valid)
|
||||
if (hasValue(data.payload.registrationType) && data.payload.registrationType.includes(AuthRegistrationType.Validation)) {
|
||||
return of(true);
|
||||
} else {
|
||||
return this.authService.isAuthenticated();
|
||||
}
|
||||
}
|
||||
return of(false);
|
||||
}
|
||||
),
|
||||
tap((isValid: boolean) => {
|
||||
if (!isValid) {
|
||||
this.router.navigate(['/404']);
|
||||
}
|
||||
}),
|
||||
catchError(() => {
|
||||
this.router.navigate(['/404']);
|
||||
return of(false);
|
||||
})
|
||||
);
|
||||
} else {
|
||||
this.router.navigate(['/404']);
|
||||
return of(false);
|
||||
}
|
||||
}
|
||||
}
|
@@ -0,0 +1,59 @@
|
||||
<h2> {{'external-login-validation.review-account-info.header' | translate}}</h2>
|
||||
|
||||
<ds-alert [type]="AlertTypeEnum.Info" [content]="'external-login-validation.review-account-info.info' | translate"></ds-alert>
|
||||
<div class="justify-content-center table-responsive">
|
||||
<table class="table table-lg">
|
||||
<thead class="thead-light">
|
||||
<tr>
|
||||
<th scope="col">
|
||||
<span class="h5 font-weight-bold">{{ 'external-login-validation.review-account-info.table.header.information' | translate }}</span>
|
||||
</th>
|
||||
<th scope="col">
|
||||
<span class="h5 font-weight-bold">{{'external-login-validation.review-account-info.table.header.received-value' | translate }}</span>
|
||||
</th>
|
||||
<th scope="col">
|
||||
<span class="h5 font-weight-bold">{{'external-login-validation.review-account-info.table.header.current-value' | translate }}</span>
|
||||
</th>
|
||||
<th scope="col"><span class="h5 font-weight-bold">{{'external-login-validation.review-account-info.table.header.action' | translate }}</span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<th scope="row text-uppercase">{{ registrationData.registrationType }}</th>
|
||||
<td>{{ registrationData.netId }}</td>
|
||||
<td>
|
||||
<span>
|
||||
{{ notApplicableText }}
|
||||
</span>
|
||||
</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
<tr *ngFor="let data of dataToCompare">
|
||||
<th scope="row">{{ data.label | titlecase }}</th>
|
||||
<td>{{ data.receivedValue }}</td>
|
||||
<td>
|
||||
<span
|
||||
[innerHTML]="
|
||||
data.receivedValue | dsCompareValues : data.currentValue
|
||||
"
|
||||
>
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<ui-switch
|
||||
*ngIf="(data.receivedValue !== data.currentValue) && data.currentValue"
|
||||
[checkedLabel]="'on-label' | translate"
|
||||
[uncheckedLabel]="'off-label' | translate"
|
||||
[checked]="data.overrideValue"
|
||||
(change)="onOverrideChange($event, data.identifier)"
|
||||
></ui-switch>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="d-flex justify-content-end">
|
||||
<button class="btn btn-primary" (click)="onSave()">
|
||||
{{'confirmation-modal.review-account-info.confirm' | translate}}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
@@ -0,0 +1,13 @@
|
||||
:host {
|
||||
table {
|
||||
tbody {
|
||||
background-color: #f7f8f9;
|
||||
}
|
||||
|
||||
td,
|
||||
th {
|
||||
height: 60px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
}
|
@@ -0,0 +1,229 @@
|
||||
import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing';
|
||||
|
||||
import { ReviewAccountInfoComponent } from './review-account-info.component';
|
||||
import { TranslateLoader, TranslateModule, TranslateService, } from '@ngx-translate/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { TranslateLoaderMock } from '../../shared/mocks/translate-loader.mock';
|
||||
import { EPersonDataService } from '../../core/eperson/eperson-data.service';
|
||||
import { Observable, of, Subscription } from 'rxjs';
|
||||
import { RemoteData } from '../../core/data/remote-data';
|
||||
import { EPerson } from '../../core/eperson/models/eperson.model';
|
||||
import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
|
||||
import { EPersonMock } from '../../shared/testing/eperson.mock';
|
||||
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { NotificationsService } from '../../shared/notifications/notifications.service';
|
||||
import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub';
|
||||
import { Router } from '@angular/router';
|
||||
import { RouterMock } from '../../shared/mocks/router.mock';
|
||||
import { EventEmitter } from '@angular/core';
|
||||
import { CompareValuesPipe } from '../helpers/compare-values.pipe';
|
||||
import { Registration } from '../../core/shared/registration.model';
|
||||
import { AuthService } from '../../core/auth/auth.service';
|
||||
import { AuthServiceMock } from '../../shared/mocks/auth.service.mock';
|
||||
import { HardRedirectService } from '../../core/services/hard-redirect.service';
|
||||
import { ExternalLoginService } from '../../external-log-in/services/external-login.service';
|
||||
import { NativeWindowService } from '../../core/services/window.service';
|
||||
import { NativeWindowMockFactory } from '../../shared/mocks/mock-native-window-ref';
|
||||
|
||||
describe('ReviewAccountInfoComponent', () => {
|
||||
let component: ReviewAccountInfoComponent;
|
||||
let componentAsAny: any;
|
||||
let fixture: ComponentFixture<ReviewAccountInfoComponent>;
|
||||
let ePersonDataServiceStub: any;
|
||||
let router: any;
|
||||
let notificationsService: any;
|
||||
let externalLoginServiceStub: any;
|
||||
let hardRedirectService: HardRedirectService;
|
||||
let authService: any;
|
||||
|
||||
const translateServiceStub = {
|
||||
get: () => of('test-message'),
|
||||
onLangChange: new EventEmitter(),
|
||||
onTranslationChange: new EventEmitter(),
|
||||
onDefaultLangChange: new EventEmitter()
|
||||
};
|
||||
const mockEPerson = EPersonMock;
|
||||
const modalStub = {
|
||||
open: () => ({ componentInstance: { response: of(true) }}),
|
||||
close: () => null,
|
||||
dismiss: () => null,
|
||||
};
|
||||
const registrationDataMock = {
|
||||
registrationType: 'orcid',
|
||||
email: 'test@test.com',
|
||||
netId: '0000-0000-0000-0000',
|
||||
user: 'a44d8c9e-9b1f-4e7f-9b1a-5c9d8a0b1f1a',
|
||||
registrationMetadata: {
|
||||
'email': [{ value: 'test@test.com' }],
|
||||
'eperson.lastname': [{ value: 'Doe' }],
|
||||
'eperson.firstname': [{ value: 'John' }],
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
ePersonDataServiceStub = {
|
||||
findById(uuid: string): Observable<RemoteData<EPerson>> {
|
||||
return createSuccessfulRemoteDataObject$(mockEPerson);
|
||||
},
|
||||
mergeEPersonDataWithToken(
|
||||
token: string,
|
||||
metadata?: string
|
||||
): Observable<RemoteData<EPerson>> {
|
||||
return createSuccessfulRemoteDataObject$(mockEPerson);
|
||||
},
|
||||
};
|
||||
router = new RouterMock();
|
||||
notificationsService = new NotificationsServiceStub();
|
||||
externalLoginServiceStub = {
|
||||
getExternalAuthLocation: () => of('https://orcid.org/oauth/authorize'),
|
||||
};
|
||||
hardRedirectService = jasmine.createSpyObj('HardRedirectService', {
|
||||
redirect: (url: string) => null,
|
||||
});
|
||||
authService = new AuthServiceMock();
|
||||
await TestBed.configureTestingModule({
|
||||
declarations: [ReviewAccountInfoComponent, CompareValuesPipe],
|
||||
providers: [
|
||||
{ provide: NativeWindowService, useFactory: NativeWindowMockFactory },
|
||||
{ provide: EPersonDataService, useValue: ePersonDataServiceStub },
|
||||
{ provide: NgbModal, useValue: modalStub },
|
||||
{
|
||||
provide: NotificationsService,
|
||||
useValue: notificationsService,
|
||||
},
|
||||
{ provide: TranslateService, useValue: translateServiceStub },
|
||||
{ provide: Router, useValue: router },
|
||||
{ provide: AuthService, useValue: authService },
|
||||
{ provide: ExternalLoginService, useValue: externalLoginServiceStub },
|
||||
{ provide: HardRedirectService, useValue: hardRedirectService },
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
TranslateModule.forRoot({
|
||||
loader: {
|
||||
provide: TranslateLoader,
|
||||
useClass: TranslateLoaderMock,
|
||||
},
|
||||
}),
|
||||
],
|
||||
}).compileComponents();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(ReviewAccountInfoComponent);
|
||||
component = fixture.componentInstance;
|
||||
componentAsAny = component;
|
||||
component.registrationData = Object.assign(
|
||||
new Registration(),
|
||||
registrationDataMock
|
||||
);
|
||||
component.registrationToken = 'test-token';
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should prepare data to compare', () => {
|
||||
component.ngOnInit();
|
||||
const dataToCompare = component.dataToCompare;
|
||||
expect(dataToCompare.length).toBe(3);
|
||||
expect(dataToCompare[0].label).toBe('email');
|
||||
expect(dataToCompare[1].label).toBe('lastname');
|
||||
expect(dataToCompare[2].label).toBe('firstname');
|
||||
expect(dataToCompare[0].overrideValue).toBe(false);
|
||||
expect(dataToCompare[0].receivedValue).toBe('test@test.com');
|
||||
});
|
||||
|
||||
it('should update dataToCompare when overrideValue is changed', () => {
|
||||
component.onOverrideChange(true, 'email');
|
||||
expect(component.dataToCompare[0].overrideValue).toBe(true);
|
||||
});
|
||||
|
||||
it('should open a confirmation modal on onSave and confirm', fakeAsync(() => {
|
||||
spyOn(modalStub, 'open').and.returnValue({
|
||||
componentInstance: { response: of(true) },
|
||||
});
|
||||
spyOn(component, 'mergeEPersonDataWithToken');
|
||||
component.onSave();
|
||||
tick();
|
||||
expect(modalStub.open).toHaveBeenCalled();
|
||||
expect(component.mergeEPersonDataWithToken).toHaveBeenCalled();
|
||||
}));
|
||||
|
||||
it('should open a confirmation modal on onSave and cancel', fakeAsync(() => {
|
||||
spyOn(modalStub, 'open').and.returnValue({
|
||||
componentInstance: { response: of(false) },
|
||||
});
|
||||
spyOn(component, 'mergeEPersonDataWithToken');
|
||||
component.onSave();
|
||||
tick();
|
||||
expect(modalStub.open).toHaveBeenCalled();
|
||||
expect(component.mergeEPersonDataWithToken).not.toHaveBeenCalled();
|
||||
}));
|
||||
|
||||
it('should merge EPerson data with token when overrideValue is true', fakeAsync(() => {
|
||||
component.dataToCompare[0].overrideValue = true;
|
||||
spyOn(ePersonDataServiceStub, 'mergeEPersonDataWithToken').and.returnValue(
|
||||
of({ hasSucceeded: true })
|
||||
);
|
||||
component.mergeEPersonDataWithToken(registrationDataMock.user, registrationDataMock.registrationType);
|
||||
tick();
|
||||
expect(ePersonDataServiceStub.mergeEPersonDataWithToken).toHaveBeenCalledTimes(1);
|
||||
expect(router.navigate).toHaveBeenCalledWith(['/profile']);
|
||||
}));
|
||||
|
||||
it('should display registration data', () => {
|
||||
const registrationTypeElement: HTMLElement = fixture.nativeElement.querySelector('tbody tr:first-child th');
|
||||
const netIdElement: HTMLElement = fixture.nativeElement.querySelector('tbody tr:first-child td');
|
||||
|
||||
expect(registrationTypeElement.textContent.trim()).toBe(registrationDataMock.registrationType);
|
||||
expect(netIdElement.textContent.trim()).toBe(registrationDataMock.netId);
|
||||
});
|
||||
|
||||
it('should display dataToCompare rows with translated labels and values', () => {
|
||||
const dataRows: NodeListOf<HTMLElement> = fixture.nativeElement.querySelectorAll('tbody tr:not(:first-child)');
|
||||
// Assuming there are 3 dataToCompare rows based on the registrationDataMock
|
||||
expect(dataRows.length).toBe(3);
|
||||
// Assuming the first row is the email row abd the second row is the lastname row
|
||||
const firstDataRow = dataRows[1];
|
||||
const firstDataLabel: HTMLElement = firstDataRow.querySelector('th');
|
||||
const firstDataReceivedValue: HTMLElement = firstDataRow.querySelectorAll('td')[0];
|
||||
const firstDataOverrideSwitch: HTMLElement = firstDataRow.querySelector('ui-switch');
|
||||
expect(firstDataLabel.textContent.trim()).toBe('Lastname');
|
||||
expect(firstDataReceivedValue.textContent.trim()).toBe('Doe');
|
||||
expect(firstDataOverrideSwitch).toBeNull();
|
||||
});
|
||||
|
||||
it('should trigger onSave() when the button is clicked', () => {
|
||||
spyOn(component, 'onSave');
|
||||
const saveButton: HTMLButtonElement = fixture.nativeElement.querySelector('button.btn-primary');
|
||||
saveButton.click();
|
||||
expect(component.onSave).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should unsubscribe from subscriptions when ngOnDestroy is called', () => {
|
||||
const subscription1 = jasmine.createSpyObj<Subscription>('Subscription', [
|
||||
'unsubscribe',
|
||||
]);
|
||||
const subscription2 = jasmine.createSpyObj<Subscription>('Subscription', [
|
||||
'unsubscribe',
|
||||
]);
|
||||
component.subs = [subscription1, subscription2];
|
||||
component.ngOnDestroy();
|
||||
expect(subscription1.unsubscribe).toHaveBeenCalled();
|
||||
expect(subscription2.unsubscribe).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle authenticated user', () => {
|
||||
const override$ = createSuccessfulRemoteDataObject$(new EPerson());
|
||||
component.handleAuthenticatedUser(override$);
|
||||
expect(componentAsAny.notificationService.success).toHaveBeenCalled();
|
||||
expect(router.navigate).toHaveBeenCalledWith(['/profile']);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fixture.destroy();
|
||||
});
|
||||
});
|
@@ -0,0 +1,281 @@
|
||||
import { ChangeDetectionStrategy, Component, Inject, Input, OnDestroy, OnInit, } from '@angular/core';
|
||||
import { EPerson } from '../../core/eperson/models/eperson.model';
|
||||
import { EPersonDataService } from '../../core/eperson/eperson-data.service';
|
||||
import { combineLatest, filter, from, map, Observable, Subscription, switchMap, take, tap } from 'rxjs';
|
||||
import { RemoteData } from '../../core/data/remote-data';
|
||||
import { ConfirmationModalComponent } from '../../shared/confirmation-modal/confirmation-modal.component';
|
||||
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { hasValue } from '../../shared/empty.util';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { NotificationsService } from '../../shared/notifications/notifications.service';
|
||||
import { Router } from '@angular/router';
|
||||
import { Registration } from '../../core/shared/registration.model';
|
||||
import { AuthService } from '../../core/auth/auth.service';
|
||||
import { HardRedirectService } from '../../core/services/hard-redirect.service';
|
||||
import { AuthRegistrationType } from '../../core/auth/models/auth.registration-type';
|
||||
import { ExternalLoginService } from '../../external-log-in/services/external-login.service';
|
||||
import { NativeWindowRef, NativeWindowService } from '../../core/services/window.service';
|
||||
import { AlertType } from '../../shared/alert/alert-type';
|
||||
|
||||
export interface ReviewAccountInfoData {
|
||||
label: string;
|
||||
currentValue: string;
|
||||
receivedValue: string;
|
||||
overrideValue: boolean;
|
||||
identifier: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'ds-review-account-info',
|
||||
templateUrl: './review-account-info.component.html',
|
||||
styleUrls: ['./review-account-info.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ReviewAccountInfoComponent implements OnInit, OnDestroy {
|
||||
/**
|
||||
* The AlertType enumeration for access in the component's template
|
||||
* @type {AlertType}
|
||||
*/
|
||||
public AlertTypeEnum = AlertType;
|
||||
/**
|
||||
* The registration token sent from validation link
|
||||
*/
|
||||
@Input() registrationToken: string;
|
||||
/**
|
||||
* User data from the registration token
|
||||
*/
|
||||
@Input() registrationData: Registration;
|
||||
|
||||
/**
|
||||
* Text to display when the value is not applicable
|
||||
*/
|
||||
notApplicableText = 'N/A';
|
||||
/**
|
||||
* List of data to compare
|
||||
*/
|
||||
dataToCompare: ReviewAccountInfoData[] = [];
|
||||
/**
|
||||
* List of subscriptions
|
||||
*/
|
||||
subs: Subscription[] = [];
|
||||
|
||||
constructor(
|
||||
@Inject(NativeWindowService) protected _window: NativeWindowRef,
|
||||
private ePersonService: EPersonDataService,
|
||||
private modalService: NgbModal,
|
||||
private notificationService: NotificationsService,
|
||||
private translateService: TranslateService,
|
||||
private router: Router,
|
||||
private authService: AuthService,
|
||||
private externalLoginService: ExternalLoginService,
|
||||
private hardRedirectService: HardRedirectService,
|
||||
) { }
|
||||
|
||||
ngOnInit(): void {
|
||||
this.dataToCompare = this.prepareDataToCompare();
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the data to compare based on the metadata key and update the override value
|
||||
* @param value value of the override checkbox
|
||||
* @param identifier the metadata key
|
||||
*/
|
||||
public onOverrideChange(value: boolean, identifier: string) {
|
||||
this.dataToCompare.find(
|
||||
(data) => data.identifier === identifier
|
||||
).overrideValue = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a confirmation modal to confirm the override of the data
|
||||
* If confirmed, merge the data from the registration token with the data from the eperson.
|
||||
* There are 2 cases:
|
||||
* -> If the user is authenticated, merge the data and redirect to profile page.
|
||||
* -> If the user is not authenticated, combine the override$, external auth location and redirect URL observables.
|
||||
*/
|
||||
public onSave() {
|
||||
const modalRef = this.modalService.open(ConfirmationModalComponent);
|
||||
modalRef.componentInstance.headerLabel =
|
||||
'confirmation-modal.review-account-info.header';
|
||||
modalRef.componentInstance.infoLabel =
|
||||
'confirmation-modal.review-account-info.info';
|
||||
modalRef.componentInstance.cancelLabel =
|
||||
'confirmation-modal.review-account-info.cancel';
|
||||
modalRef.componentInstance.confirmLabel =
|
||||
'confirmation-modal.review-account-info.confirm';
|
||||
modalRef.componentInstance.brandColor = 'primary';
|
||||
modalRef.componentInstance.confirmIcon = 'fa fa-check';
|
||||
|
||||
if (!this.registrationData.user) {
|
||||
this.subs.push(
|
||||
this.isAuthenticated()
|
||||
.pipe(
|
||||
filter((isAuthenticated) => isAuthenticated),
|
||||
switchMap(() => this.authService.getAuthenticatedUserFromStore()),
|
||||
filter((user) => hasValue(user)),
|
||||
map((user) => user.uuid),
|
||||
switchMap((userId) =>
|
||||
modalRef.componentInstance.response.pipe(
|
||||
tap((confirm: boolean) => {
|
||||
if (confirm) {
|
||||
this.mergeEPersonDataWithToken(userId, this.registrationData.registrationType);
|
||||
}
|
||||
})
|
||||
)
|
||||
)
|
||||
)
|
||||
.subscribe()
|
||||
);
|
||||
} else if (this.registrationData.user) {
|
||||
this.subs.push(
|
||||
modalRef.componentInstance.response
|
||||
.pipe(take(1))
|
||||
.subscribe((confirm: boolean) => {
|
||||
if (confirm && this.registrationData.user) {
|
||||
const registrationType = this.registrationData.registrationType.split(AuthRegistrationType.Validation)[1];
|
||||
this.mergeEPersonDataWithToken(this.registrationData.user, registrationType);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge the data from the registration token with the data from the eperson.
|
||||
* If any of the metadata is overridden, sent a merge request for each metadata to override.
|
||||
* If none of the metadata is overridden, sent a merge request with the registration token only.
|
||||
*/
|
||||
mergeEPersonDataWithToken(userId: string, registrationType: string) {
|
||||
let override$: Observable<RemoteData<EPerson>>;
|
||||
if (this.dataToCompare.some((d) => d.overrideValue)) {
|
||||
override$ = from(this.dataToCompare).pipe(
|
||||
filter((data: ReviewAccountInfoData) => data.overrideValue),
|
||||
switchMap((data: ReviewAccountInfoData) => {
|
||||
return this.ePersonService.mergeEPersonDataWithToken(
|
||||
userId,
|
||||
this.registrationToken,
|
||||
data.identifier
|
||||
);
|
||||
})
|
||||
);
|
||||
} else {
|
||||
override$ = this.ePersonService.mergeEPersonDataWithToken(
|
||||
userId,
|
||||
this.registrationToken
|
||||
);
|
||||
}
|
||||
if (this.registrationData.user && this.registrationData.registrationType.includes(AuthRegistrationType.Validation)) {
|
||||
this.handleUnauthenticatedUser(override$, registrationType);
|
||||
} else {
|
||||
this.handleAuthenticatedUser(override$);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the authenticated user by subscribing to the override$ observable and displaying a success or error notification based on the response.
|
||||
* If the response has succeeded, the user is redirected to the profile page.
|
||||
* @param override$ - The observable that emits the response containing the RemoteData<EPerson> object.
|
||||
*/
|
||||
handleAuthenticatedUser(override$: Observable<RemoteData<EPerson>>) {
|
||||
this.subs.push(
|
||||
override$.subscribe((response: RemoteData<EPerson>) => {
|
||||
if (response.hasSucceeded) {
|
||||
this.notificationService.success(
|
||||
this.translateService.get(
|
||||
'review-account-info.merge-data.notification.success'
|
||||
)
|
||||
);
|
||||
this.router.navigate(['/profile']);
|
||||
} else if (response.hasFailed) {
|
||||
this.notificationService.error(
|
||||
this.translateService.get(
|
||||
'review-account-info.merge-data.notification.error'
|
||||
)
|
||||
);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles unauthenticated user by combining the override$, external auth location and redirect URL observables.
|
||||
* If the response has succeeded, sets the redirect URL to user profile and redirects to external registration type authentication URL.
|
||||
* If the response has failed, shows an error notification.
|
||||
* @param override$ - The override$ observable.
|
||||
* @param registrationType - The registration type.
|
||||
*/
|
||||
handleUnauthenticatedUser(override$: Observable<RemoteData<EPerson>>, registrationType: string) {
|
||||
this.subs.push(
|
||||
combineLatest([
|
||||
override$,
|
||||
this.externalLoginService.getExternalAuthLocation(registrationType),
|
||||
this.authService.getRedirectUrl()])
|
||||
.subscribe(([response, location, redirectRoute]) => {
|
||||
if (response.hasSucceeded) {
|
||||
this.notificationService.success(
|
||||
this.translateService.get(
|
||||
'review-account-info.merge-data.notification.success'
|
||||
)
|
||||
);
|
||||
// set Redirect URL to User profile, so the user is redirected to the profile page after logging in
|
||||
this.authService.setRedirectUrl('/profile');
|
||||
const externalServerUrl = this.authService.getExternalServerRedirectUrl(
|
||||
this._window.nativeWindow.origin,
|
||||
redirectRoute,
|
||||
location
|
||||
);
|
||||
// redirect to external registration type authentication url
|
||||
this.hardRedirectService.redirect(externalServerUrl);
|
||||
} else if (response.hasFailed) {
|
||||
this.notificationService.error(
|
||||
this.translateService.get(
|
||||
'review-account-info.merge-data.notification.error'
|
||||
)
|
||||
);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the user is authenticated.
|
||||
* @returns An observable that emits a boolean value indicating whether the user is authenticated or not.
|
||||
*/
|
||||
private isAuthenticated(): Observable<boolean> {
|
||||
return this.authService.isAuthenticated();
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare the data to compare and display:
|
||||
* -> For each metadata from the registration token, get the current value from the eperson.
|
||||
* -> Label is the metadata key without the prefix e.g `eperson.` but only `email`
|
||||
* -> Identifier is the metadata key with the prefix e.g `eperson.lastname`
|
||||
* -> Override value is false by default
|
||||
* @returns List of data to compare
|
||||
*/
|
||||
private prepareDataToCompare(): ReviewAccountInfoData[] {
|
||||
const dataToCompare: ReviewAccountInfoData[] = [];
|
||||
Object.entries(this.registrationData.registrationMetadata).forEach(
|
||||
([key, value]) => {
|
||||
// eperson.orcid is not always present in the registration metadata,
|
||||
// so display netId instead and skip it in the metadata in order not to have duplicate data.
|
||||
if (value[0].value === this.registrationData.netId) {
|
||||
return;
|
||||
}
|
||||
dataToCompare.push({
|
||||
label: key.split('.')?.[1] ?? key.split('.')?.[0],
|
||||
currentValue: value[0]?.overrides ?? '',
|
||||
receivedValue: value[0].value,
|
||||
overrideValue: false,
|
||||
identifier: key,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
return dataToCompare;
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.subs.filter((s) => hasValue(s)).forEach((sub) => sub.unsubscribe());
|
||||
}
|
||||
}
|
@@ -0,0 +1,25 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { ThemedComponent } from '../shared/theme-support/themed.component';
|
||||
import { ExternalLoginReviewAccountInfoPageComponent } from './external-login-review-account-info-page.component';
|
||||
|
||||
/**
|
||||
* Themed wrapper for ExternalLoginReviewAccountInfoPageComponent
|
||||
*/
|
||||
@Component({
|
||||
selector: 'ds-themed-external-login-page',
|
||||
styleUrls: [],
|
||||
templateUrl: './../shared/theme-support/themed.component.html'
|
||||
})
|
||||
export class ThemedExternalLoginReviewAccountInfoPageComponent extends ThemedComponent<ExternalLoginReviewAccountInfoPageComponent> {
|
||||
protected getComponentName(): string {
|
||||
return 'ExternalLoginReviewAccountInfoPageComponent';
|
||||
}
|
||||
|
||||
protected importThemedComponent(themeName: string): Promise<any> {
|
||||
return import(`../../themes/${themeName}/app/external-login-review-account-info/external-login-review-account-info-page.component`);
|
||||
}
|
||||
|
||||
protected importUnthemedComponent(): Promise<any> {
|
||||
return import(`./external-login-review-account-info-page.component`);
|
||||
}
|
||||
}
|
@@ -14,7 +14,7 @@ describe('registrationResolver', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
epersonRegistrationService = jasmine.createSpyObj('epersonRegistrationService', {
|
||||
searchByToken: createSuccessfulRemoteDataObject$(registration),
|
||||
searchByTokenAndUpdateData: createSuccessfulRemoteDataObject$(registration)
|
||||
});
|
||||
resolver = registrationResolver;
|
||||
});
|
||||
|
@@ -1,9 +1,5 @@
|
||||
import { inject } from '@angular/core';
|
||||
import {
|
||||
ActivatedRouteSnapshot,
|
||||
ResolveFn,
|
||||
RouterStateSnapshot,
|
||||
} from '@angular/router';
|
||||
import { ActivatedRouteSnapshot, ResolveFn, RouterStateSnapshot, } from '@angular/router';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
import { EpersonRegistrationService } from '../core/data/eperson-registration.service';
|
||||
@@ -17,7 +13,7 @@ export const registrationResolver: ResolveFn<RemoteData<Registration>> = (
|
||||
epersonRegistrationService: EpersonRegistrationService = inject(EpersonRegistrationService),
|
||||
): Observable<RemoteData<Registration>> => {
|
||||
const token = route.params.token;
|
||||
return epersonRegistrationService.searchByToken(token).pipe(
|
||||
return epersonRegistrationService.searchByTokenAndUpdateData(token).pipe(
|
||||
getFirstCompletedRemoteData(),
|
||||
);
|
||||
};
|
||||
|
@@ -1,18 +1,11 @@
|
||||
import {
|
||||
ActivatedRouteSnapshot,
|
||||
Router,
|
||||
RouterStateSnapshot,
|
||||
} from '@angular/router';
|
||||
import { of as observableOf } from 'rxjs';
|
||||
import { ActivatedRouteSnapshot, Router, RouterStateSnapshot, } from '@angular/router';
|
||||
|
||||
import { AuthService } from '../core/auth/auth.service';
|
||||
import { EpersonRegistrationService } from '../core/data/eperson-registration.service';
|
||||
import { RemoteData } from '../core/data/remote-data';
|
||||
import { createFailedRemoteDataObject$, createSuccessfulRemoteDataObject, } from '../shared/remote-data.utils';
|
||||
import { Registration } from '../core/shared/registration.model';
|
||||
import {
|
||||
createFailedRemoteDataObject$,
|
||||
createSuccessfulRemoteDataObject,
|
||||
} from '../shared/remote-data.utils';
|
||||
import { of as observableOf } from 'rxjs/internal/observable/of';
|
||||
import { RemoteData } from '../core/data/remote-data';
|
||||
import { registrationGuard } from './registration.guard';
|
||||
|
||||
describe('registrationGuard', () => {
|
||||
@@ -53,7 +46,7 @@ describe('registrationGuard', () => {
|
||||
});
|
||||
|
||||
epersonRegistrationService = jasmine.createSpyObj('epersonRegistrationService', {
|
||||
searchByToken: observableOf(registrationRD),
|
||||
searchByTokenAndUpdateData: observableOf(registrationRD),
|
||||
});
|
||||
router = jasmine.createSpyObj('router', {
|
||||
navigateByUrl: Promise.resolve(),
|
||||
@@ -71,7 +64,7 @@ describe('registrationGuard', () => {
|
||||
describe('canActivate', () => {
|
||||
describe('when searchByToken returns a successful response', () => {
|
||||
beforeEach(() => {
|
||||
(epersonRegistrationService.searchByToken as jasmine.Spy).and.returnValue(observableOf(registrationRD));
|
||||
(epersonRegistrationService.searchByTokenAndUpdateData as jasmine.Spy).and.returnValue(observableOf(registrationRD));
|
||||
});
|
||||
|
||||
it('should return true', (done) => {
|
||||
@@ -98,7 +91,7 @@ describe('registrationGuard', () => {
|
||||
|
||||
describe('when searchByToken returns a 404 response', () => {
|
||||
beforeEach(() => {
|
||||
(epersonRegistrationService.searchByToken as jasmine.Spy).and.returnValue(createFailedRemoteDataObject$('Not Found', 404));
|
||||
(epersonRegistrationService.searchByTokenAndUpdateData as jasmine.Spy).and.returnValue(createFailedRemoteDataObject$('Not Found', 404));
|
||||
});
|
||||
|
||||
it('should redirect', () => {
|
||||
|
@@ -2,4 +2,3 @@
|
||||
*ngComponentOutlet="getAuthMethodContent();
|
||||
injector: objectInjector;">
|
||||
</ng-container>
|
||||
|
||||
|
@@ -1,22 +1,7 @@
|
||||
import {
|
||||
AsyncPipe,
|
||||
NgFor,
|
||||
NgIf,
|
||||
} from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
Input,
|
||||
OnInit,
|
||||
} from '@angular/core';
|
||||
import {
|
||||
select,
|
||||
Store,
|
||||
} from '@ngrx/store';
|
||||
import {
|
||||
map,
|
||||
Observable,
|
||||
} from 'rxjs';
|
||||
import { AsyncPipe, NgFor, NgIf, } from '@angular/common';
|
||||
import { ChangeDetectionStrategy, Component, Input, OnInit, } from '@angular/core';
|
||||
import { select, Store, } from '@ngrx/store';
|
||||
import { Observable, } from 'rxjs';
|
||||
|
||||
import { AuthService } from '../../core/auth/auth.service';
|
||||
import { AuthMethod } from '../../core/auth/models/auth.method';
|
||||
@@ -27,10 +12,13 @@ import {
|
||||
isAuthenticationLoading,
|
||||
} from '../../core/auth/selectors';
|
||||
import { CoreState } from '../../core/core-state.model';
|
||||
import { AuthMethodType } from '../../core/auth/models/auth.method-type';
|
||||
import { map } from 'rxjs/operators';
|
||||
import { hasValue } from '../empty.util';
|
||||
import { ThemedLoadingComponent } from '../loading/themed-loading.component';
|
||||
import { LogInContainerComponent } from './container/log-in-container.component';
|
||||
import { rendersAuthMethodType } from './methods/log-in.methods-decorator';
|
||||
import uniqBy from 'lodash/uniqBy';
|
||||
|
||||
@Component({
|
||||
selector: 'ds-base-log-in',
|
||||
@@ -48,6 +36,15 @@ export class LogInComponent implements OnInit {
|
||||
*/
|
||||
@Input() isStandalonePage: boolean;
|
||||
|
||||
/**
|
||||
* Method to exclude from the list of authentication methods
|
||||
*/
|
||||
@Input() excludedAuthMethod: AuthMethodType;
|
||||
/**
|
||||
* Weather or not to show the register link
|
||||
*/
|
||||
@Input() showRegisterLink = true;
|
||||
|
||||
/**
|
||||
* The list of authentication methods available
|
||||
* @type {AuthMethod[]}
|
||||
@@ -75,9 +72,13 @@ export class LogInComponent implements OnInit {
|
||||
this.authMethods = this.store.pipe(
|
||||
select(getAuthenticationMethods),
|
||||
map((methods: AuthMethod[]) => methods
|
||||
// ignore the given auth method if it should be excluded
|
||||
.filter((authMethod: AuthMethod) => authMethod.authMethodType !== this.excludedAuthMethod)
|
||||
.filter((authMethod: AuthMethod) => rendersAuthMethodType(authMethod.authMethodType) !== undefined)
|
||||
.sort((method1: AuthMethod, method2: AuthMethod) => method1.position - method2.position),
|
||||
),
|
||||
// ignore the ip authentication method when it's returned by the backend
|
||||
map((authMethods: AuthMethod[]) => uniqBy(authMethods.filter(a => a.authMethodType !== AuthMethodType.Ip), 'authMethodType'))
|
||||
);
|
||||
|
||||
// set loading
|
||||
|
@@ -1,13 +1,6 @@
|
||||
import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
|
||||
import {
|
||||
ComponentFixture,
|
||||
TestBed,
|
||||
waitForAsync,
|
||||
} from '@angular/core/testing';
|
||||
import {
|
||||
ActivatedRoute,
|
||||
Router,
|
||||
} from '@angular/router';
|
||||
import { ComponentFixture, TestBed, waitForAsync, } from '@angular/core/testing';
|
||||
import { ActivatedRoute, Router, } from '@angular/router';
|
||||
import { StoreModule } from '@ngrx/store';
|
||||
import { provideMockStore } from '@ngrx/store/testing';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
@@ -108,8 +101,7 @@ describe('LogInExternalProviderComponent', () => {
|
||||
|
||||
component.redirectToExternalProvider();
|
||||
|
||||
expect(setHrefSpy).toHaveBeenCalledWith(currentUrl);
|
||||
|
||||
expect(hardRedirectService.redirect).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not set a new redirectUrl', () => {
|
||||
|
@@ -1,33 +1,17 @@
|
||||
import {
|
||||
Component,
|
||||
Inject,
|
||||
OnInit,
|
||||
} from '@angular/core';
|
||||
import {
|
||||
select,
|
||||
Store,
|
||||
} from '@ngrx/store';
|
||||
import { Component, Inject, OnInit, } from '@angular/core';
|
||||
import { select, Store, } from '@ngrx/store';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { Observable } from 'rxjs';
|
||||
import { take } from 'rxjs/operators';
|
||||
|
||||
import { AuthService } from '../../../../core/auth/auth.service';
|
||||
import { AuthMethod } from '../../../../core/auth/models/auth.method';
|
||||
import {
|
||||
isAuthenticated,
|
||||
isAuthenticationLoading,
|
||||
} from '../../../../core/auth/selectors';
|
||||
|
||||
import { isAuthenticated, isAuthenticationLoading } from '../../../../core/auth/selectors';
|
||||
import { NativeWindowRef, NativeWindowService } from '../../../../core/services/window.service';
|
||||
import { isEmpty } from '../../../empty.util';
|
||||
import { AuthService } from '../../../../core/auth/auth.service';
|
||||
import { CoreState } from '../../../../core/core-state.model';
|
||||
import { HardRedirectService } from '../../../../core/services/hard-redirect.service';
|
||||
import {
|
||||
NativeWindowRef,
|
||||
NativeWindowService,
|
||||
} from '../../../../core/services/window.service';
|
||||
import { URLCombiner } from '../../../../core/url-combiner/url-combiner';
|
||||
import {
|
||||
isEmpty,
|
||||
isNotNull,
|
||||
} from '../../../empty.util';
|
||||
|
||||
@Component({
|
||||
selector: 'ds-log-in-external-provider',
|
||||
@@ -104,24 +88,14 @@ export class LogInExternalProviderComponent implements OnInit {
|
||||
} else if (isEmpty(redirectRoute)) {
|
||||
redirectRoute = '/';
|
||||
}
|
||||
const correctRedirectUrl = new URLCombiner(this._window.nativeWindow.origin, redirectRoute).toString();
|
||||
|
||||
let externalServerUrl = this.location;
|
||||
const myRegexp = /\?redirectUrl=(.*)/g;
|
||||
const match = myRegexp.exec(this.location);
|
||||
const redirectUrlFromServer = (match && match[1]) ? match[1] : null;
|
||||
|
||||
// Check whether the current page is different from the redirect url received from rest
|
||||
if (isNotNull(redirectUrlFromServer) && redirectUrlFromServer !== correctRedirectUrl) {
|
||||
// change the redirect url with the current page url
|
||||
const newRedirectUrl = `?redirectUrl=${correctRedirectUrl}`;
|
||||
externalServerUrl = this.location.replace(/\?redirectUrl=(.*)/g, newRedirectUrl);
|
||||
}
|
||||
|
||||
// redirect to shibboleth authentication url
|
||||
const externalServerUrl = this.authService.getExternalServerRedirectUrl(
|
||||
this._window.nativeWindow.origin,
|
||||
redirectRoute,
|
||||
this.location
|
||||
);
|
||||
// redirect to shibboleth/orcid/(external) authentication url
|
||||
this.hardRedirectService.redirect(externalServerUrl);
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
getButtonLabel() {
|
||||
|
@@ -1,8 +1,5 @@
|
||||
/* eslint-disable no-empty, @typescript-eslint/no-empty-function */
|
||||
import {
|
||||
Observable,
|
||||
of as observableOf,
|
||||
} from 'rxjs';
|
||||
import { Observable, of as observableOf, } from 'rxjs';
|
||||
|
||||
export class AuthServiceMock {
|
||||
public checksAuthenticationToken() {
|
||||
@@ -29,4 +26,16 @@ export class AuthServiceMock {
|
||||
public isUserIdle(): Observable<boolean> {
|
||||
return observableOf(false);
|
||||
}
|
||||
|
||||
public getImpersonateID(): string {
|
||||
return null;
|
||||
}
|
||||
|
||||
public getRedirectUrl(): Observable<string> {
|
||||
return;
|
||||
}
|
||||
|
||||
public getExternalServerRedirectUrl(): string {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
@@ -1,7 +1,4 @@
|
||||
import {
|
||||
Observable,
|
||||
of as observableOf,
|
||||
} from 'rxjs';
|
||||
import { Observable, of as observableOf, } from 'rxjs';
|
||||
|
||||
import { AuthMethod } from '../../core/auth/models/auth.method';
|
||||
import { AuthMethodType } from '../../core/auth/models/auth.method-type';
|
||||
@@ -11,6 +8,7 @@ import { EPerson } from '../../core/eperson/models/eperson.model';
|
||||
import { hasValue } from '../empty.util';
|
||||
import { createSuccessfulRemoteDataObject$ } from '../remote-data.utils';
|
||||
import { EPersonMock } from './eperson.mock';
|
||||
import { RetrieveAuthMethodsAction } from '../../core/auth/auth.actions';
|
||||
|
||||
export const authMethodsMock: AuthMethod[] = [
|
||||
new AuthMethod(AuthMethodType.Password, 0),
|
||||
@@ -128,6 +126,7 @@ export class AuthServiceStub {
|
||||
checkAuthenticationCookie() {
|
||||
return;
|
||||
}
|
||||
|
||||
setExternalAuthStatus(externalCookie: boolean) {
|
||||
this._isExternalAuth = externalCookie;
|
||||
}
|
||||
@@ -179,4 +178,16 @@ export class AuthServiceStub {
|
||||
clearRedirectUrl() {
|
||||
return;
|
||||
}
|
||||
|
||||
public replaceToken(token: AuthTokenInfo) {
|
||||
return token;
|
||||
}
|
||||
|
||||
getRetrieveAuthMethodsAction(authStatus: AuthStatus): RetrieveAuthMethodsAction {
|
||||
return;
|
||||
}
|
||||
|
||||
public getExternalServerRedirectUrl(redirectRoute: string, location: string) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
@@ -1828,6 +1828,14 @@
|
||||
|
||||
"confirmation-modal.delete-subscription.confirm": "Delete",
|
||||
|
||||
"confirmation-modal.review-account-info.header": "Save the changes",
|
||||
|
||||
"confirmation-modal.review-account-info.info": "Continue to update your profile",
|
||||
|
||||
"confirmation-modal.review-account-info.cancel": "Cancel",
|
||||
|
||||
"confirmation-modal.review-account-info.confirm": "Save",
|
||||
|
||||
"error.bitstream": "Error fetching bitstream",
|
||||
|
||||
"error.browse-by": "Error fetching items",
|
||||
@@ -6741,4 +6749,64 @@
|
||||
"item.page.cc.license.disclaimer": "Except where otherwised noted, this item's license is described as",
|
||||
|
||||
"browse.search-form.placeholder": "Search the repository",
|
||||
|
||||
"admin.system-wide-alert.title": "System-wide Alerts",
|
||||
|
||||
"external-login.confirmation.header": "Information needed to complete the login process",
|
||||
|
||||
"external-login.noEmail.informationText": "The information received from {{authMethod}} are not sufficient to complete the login process. Please provide the missing information below, or login via a different method to associate your {{authMethod}} to an existing account.",
|
||||
|
||||
"external-login.haveEmail.informationText": "It seems that you have not yet an account in this system. If this is the case, please confirm the data received from {{authMethod}} and a new account will be created for you. Otherwise, if you already have an account in the system, please update the email address to match the one already in use in the system or login via a different method to associate your {{authMethod}} to your existing account.",
|
||||
|
||||
"external-login.confirm-email.header": "Confirm or update email",
|
||||
|
||||
"external-login.confirmation.email-required": "Email is required.",
|
||||
|
||||
"external-login.confirmation.email-invalid": "Invalid email format.",
|
||||
|
||||
"external-login.confirm.button.label": "Confirm this email",
|
||||
|
||||
"external-login.confirm-email-sent.header": "Confirmation email sent",
|
||||
|
||||
"external-login.confirm-email-sent.info": " We have sent an email to the provided address to validate your input. <br /> Please follow the instructions in the email to complete the login process.",
|
||||
|
||||
"external-login.provide-email.header": "Provide email",
|
||||
|
||||
"external-login.provide-email.button.label": "Send Verification link",
|
||||
|
||||
"external-login-validation.review-account-info.header": "Review your account information",
|
||||
|
||||
"external-login-validation.review-account-info.info": "The information received from ORCID differs from the one recorded in your profile. <br /> Please review them and decide if you want to update any of them. After saving you will be redirected to your profile page.",
|
||||
|
||||
"external-login-validation.review-account-info.table.header.information": "Information",
|
||||
|
||||
"external-login-validation.review-account-info.table.header.received-value": "Received value",
|
||||
|
||||
"external-login-validation.review-account-info.table.header.current-value": "Current value",
|
||||
|
||||
"external-login-validation.review-account-info.table.header.action": "Override",
|
||||
|
||||
"on-label": "ON",
|
||||
|
||||
"off-label": "OFF",
|
||||
|
||||
"review-account-info.merge-data.notification.success": "Your account information has been updated successfully",
|
||||
|
||||
"review-account-info.merge-data.notification.error": "Something went wrong while updating your account information",
|
||||
|
||||
"review-account-info.alert.error.content": "Something went wrong. Please try again later.",
|
||||
|
||||
"external-login-page.provide-email.notifications.error": "Something went wrong.Email address was omitted or the operation is not valid.",
|
||||
|
||||
"external-login.error.notification": "There was an error while processing your request. Please try again later.",
|
||||
|
||||
"external-login.connect-to-existing-account.label": "Connect to an existing user",
|
||||
|
||||
"external-login.modal.label.close": "Close",
|
||||
|
||||
"external-login-page.provide-email.create-account.notifications.error.header": "Something went wrong",
|
||||
|
||||
"external-login-page.provide-email.create-account.notifications.error.content": "Please check again your email address and try again.",
|
||||
|
||||
"external-login-page.confirm-email.create-account.notifications.error.no-netId": "Something went wrong with this email account. Try again or use a different method to login.",
|
||||
}
|
||||
|
@@ -2323,6 +2323,22 @@
|
||||
// "confirmation-modal.delete-subscription.confirm": "Delete",
|
||||
"confirmation-modal.delete-subscription.confirm": "Elimina",
|
||||
|
||||
// "confirmation-modal.review-account-info.header": "Save the changes",
|
||||
// TODO New key - Add a translation
|
||||
"confirmation-modal.review-account-info.header": "Save the changes",
|
||||
|
||||
// "confirmation-modal.review-account-info.info": "Continue to update your profile",
|
||||
// TODO New key - Add a translation
|
||||
"confirmation-modal.review-account-info.info": "Continue to update your profile",
|
||||
|
||||
// "confirmation-modal.review-account-info.cancel": "Cancel",
|
||||
// TODO New key - Add a translation
|
||||
"confirmation-modal.review-account-info.cancel": "Cancel",
|
||||
|
||||
// "confirmation-modal.review-account-info.confirm": "Save",
|
||||
// TODO New key - Add a translation
|
||||
"confirmation-modal.review-account-info.confirm": "Save",
|
||||
|
||||
// "error.bitstream": "Error fetching bitstream",
|
||||
"error.bitstream": "Errore durante il recupero del bitstream",
|
||||
|
||||
@@ -7856,5 +7872,119 @@
|
||||
// "admin.system-wide-alert.title": "System-wide Alerts",
|
||||
"admin.system-wide-alert.title": "Allarmi di sistema",
|
||||
|
||||
// "external-login.confirmation.header": "Information needed to complete the login process",
|
||||
// TODO New key - Add a translation
|
||||
"external-login.confirmation.header": "Information needed to complete the login process",
|
||||
|
||||
// "external-login.noEmail.informationText": "The information received from {{authMethod}} are not sufficient to complete the login process. Please provide the missing information below, or login via a different method to associate your {{authMethod}} to an existing account.",
|
||||
// TODO New key - Add a translation
|
||||
"external-login.noEmail.informationText": "The information received from {{authMethod}} are not sufficient to complete the login process. Please provide the missing information below, or login via a different method to associate your {{authMethod}} to an existing account.",
|
||||
|
||||
// "external-login.haveEmail.informationText": "It seems that you have not yet an account in this system. If this is the case, please confirm the data received from {{authMethod}} and a new account will be created for you. Otherwise, if you already have an account in the system, please update the email address to match the one already in use in the system or login via a different method to associate your {{authMethod}} to your existing account.",
|
||||
// TODO New key - Add a translation
|
||||
"external-login.haveEmail.informationText": "It seems that you have not yet an account in this system. If this is the case, please confirm the data received from {{authMethod}} and a new account will be created for you. Otherwise, if you already have an account in the system, please update the email address to match the one already in use in the system or login via a different method to associate your {{authMethod}} to your existing account.",
|
||||
|
||||
// "external-login.confirm-email.header": "Confirm or update email",
|
||||
// TODO New key - Add a translation
|
||||
"external-login.confirm-email.header": "Confirm or update email",
|
||||
|
||||
// "external-login.confirmation.email-required": "Email is required.",
|
||||
// TODO New key - Add a translation
|
||||
"external-login.confirmation.email-required": "Email is required.",
|
||||
|
||||
// "external-login.confirmation.email-invalid": "Invalid email format.",
|
||||
// TODO New key - Add a translation
|
||||
"external-login.confirmation.email-invalid": "Invalid email format.",
|
||||
|
||||
// "external-login.confirm.button.label": "Confirm this email",
|
||||
// TODO New key - Add a translation
|
||||
"external-login.confirm.button.label": "Confirm this email",
|
||||
|
||||
// "external-login.confirm-email-sent.header": "Confirmation email sent",
|
||||
// TODO New key - Add a translation
|
||||
"external-login.confirm-email-sent.header": "Confirmation email sent",
|
||||
|
||||
// "external-login.confirm-email-sent.info": " We have sent an emait to the provided address to validate your input. <br> Please follow the instructions in the email to complete the login process.",
|
||||
// TODO New key - Add a translation
|
||||
"external-login.confirm-email-sent.info": " We have sent an emait to the provided address to validate your input. <br> Please follow the instructions in the email to complete the login process.",
|
||||
|
||||
// "external-login.provide-email.header": "Provide email",
|
||||
// TODO New key - Add a translation
|
||||
"external-login.provide-email.header": "Provide email",
|
||||
|
||||
// "external-login.provide-email.button.label": "Send Verification link",
|
||||
// TODO New key - Add a translation
|
||||
"external-login.provide-email.button.label": "Send Verification link",
|
||||
|
||||
// "external-login-validation.review-account-info.header": "Review your account information",
|
||||
// TODO New key - Add a translation
|
||||
"external-login-validation.review-account-info.header": "Review your account information",
|
||||
|
||||
// "external-login-validation.review-account-info.info": "The information received from ORCID differs from the one recorded in your profile. <br /> Please review them and decide if you want to update any of them.After saving you will be redirected to your profile page.",
|
||||
// TODO New key - Add a translation
|
||||
"external-login-validation.review-account-info.info": "The information received from ORCID differs from the one recorded in your profile. <br /> Please review them and decide if you want to update any of them.After saving you will be redirected to your profile page.",
|
||||
|
||||
// "external-login-validation.review-account-info.table.header.information": "Information",
|
||||
// TODO New key - Add a translation
|
||||
"external-login-validation.review-account-info.table.header.information": "Information",
|
||||
|
||||
// "external-login-validation.review-account-info.table.header.received-value": "Received value",
|
||||
// TODO New key - Add a translation
|
||||
"external-login-validation.review-account-info.table.header.received-value": "Received value",
|
||||
|
||||
// "external-login-validation.review-account-info.table.header.current-value": "Current value",
|
||||
// TODO New key - Add a translation
|
||||
"external-login-validation.review-account-info.table.header.current-value": "Current value",
|
||||
|
||||
// "external-login-validation.review-account-info.table.header.action": "Override",
|
||||
// TODO New key - Add a translation
|
||||
"external-login-validation.review-account-info.table.header.action": "Override",
|
||||
|
||||
// "on-label": "ON",
|
||||
// TODO New key - Add a translation
|
||||
"on-label": "ON",
|
||||
|
||||
// "off-label": "OFF",
|
||||
// TODO New key - Add a translation
|
||||
"off-label": "OFF",
|
||||
|
||||
// "review-account-info.merge-data.notification.success": "Your account information has been updated successfully",
|
||||
// TODO New key - Add a translation
|
||||
"review-account-info.merge-data.notification.success": "Your account information has been updated successfully",
|
||||
|
||||
// "review-account-info.merge-data.notification.error": "Something went wrong while updating your account information",
|
||||
// TODO New key - Add a translation
|
||||
"review-account-info.merge-data.notification.error": "Something went wrong while updating your account information",
|
||||
|
||||
// "review-account-info.alert.error.content": "Something went wrong. Please try again later.",
|
||||
// TODO New key - Add a translation
|
||||
"review-account-info.alert.error.content": "Something went wrong. Please try again later.",
|
||||
|
||||
// "external-login-page.provide-email.notifications.error": "Something went wrong.Email address was omitted or the operation is not valid.",
|
||||
// TODO New key - Add a translation
|
||||
"external-login-page.provide-email.notifications.error": "Something went wrong.Email address was omitted or the operation is not valid.",
|
||||
|
||||
// "external-login.error.notification": "There was an error while processing your request. Please try again later.",
|
||||
// TODO New key - Add a translation
|
||||
"external-login.error.notification": "There was an error while processing your request. Please try again later.",
|
||||
|
||||
// "external-login.connect-to-existing-account.label": "Connect to an existing user",
|
||||
// TODO New key - Add a translation
|
||||
"external-login.connect-to-existing-account.label": "Connect to an existing user",
|
||||
|
||||
// "external-login.modal.label.close": "Close",
|
||||
// TODO New key - Add a translation
|
||||
"external-login.modal.label.close": "Close",
|
||||
|
||||
// "external-login-page.provide-email.create-account.notifications.error.header": "Something went wrong",
|
||||
// TODO New key - Add a translation
|
||||
"external-login-page.provide-email.create-account.notifications.error.header": "Something went wrong",
|
||||
|
||||
// "external-login-page.provide-email.create-account.notifications.error.content": "Please check again your email address and try again.",
|
||||
// TODO New key - Add a translation
|
||||
"external-login-page.provide-email.create-account.notifications.error.content": "Please check again your email address and try again.",
|
||||
|
||||
// "external-login-page.confirm-email.create-account.notifications.error.no-netId": "Something went wrong with this email account. Try again or use a different method to login.",
|
||||
// TODO New key - Add a translation
|
||||
"external-login-page.confirm-email.create-account.notifications.error.no-netId": "Something went wrong with this email account. Try again or use a different method to login.",
|
||||
}
|
||||
|
Reference in New Issue
Block a user