diff --git a/src/app/app-routes.ts b/src/app/app-routes.ts index 29a78364b53..a2667cf2ac6 100644 --- a/src/app/app-routes.ts +++ b/src/app/app-routes.ts @@ -257,6 +257,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 }, ], }, diff --git a/src/app/app.config.ts b/src/app/app.config.ts index 61a04c8adbb..41f934d2bfd 100644 --- a/src/app/app.config.ts +++ b/src/app/app.config.ts @@ -63,6 +63,7 @@ import { import { ClientCookieService } from './core/services/client-cookie.service'; import { ListableModule } from './core/shared/listable.module'; import { XsrfInterceptor } from './core/xsrf/xsrf.interceptor'; +import { LOGIN_METHOD_FOR_DECORATOR_MAP } from './external-log-in/decorators/external-log-in.methods-decorator'; import { RootModule } from './root.module'; import { AUTH_METHOD_FOR_DECORATOR_MAP } from './shared/log-in/methods/log-in.methods-decorator'; import { METADATA_REPRESENTATION_COMPONENT_DECORATOR_MAP } from './shared/metadata-representation/metadata-representation.decorator'; @@ -157,6 +158,7 @@ export const commonAppConfig: ApplicationConfig = { /* Use models object so all decorators are actually called */ const modelList = models; +const loginMethodForDecoratorMap = LOGIN_METHOD_FOR_DECORATOR_MAP; const workflowTasks = WORKFLOW_TASK_OPTION_DECORATOR_MAP; const advancedWorfklowTasks = ADVANCED_WORKFLOW_TASK_OPTION_DECORATOR_MAP; const metadataRepresentations = METADATA_REPRESENTATION_COMPONENT_DECORATOR_MAP; diff --git a/src/app/core/auth/auth-request.service.ts b/src/app/core/auth/auth-request.service.ts index 5d11b9f4cb0..3fb0de9d50f 100644 --- a/src/app/core/auth/auth-request.service.ts +++ b/src/app/core/auth/auth-request.service.ts @@ -8,11 +8,15 @@ import { 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 { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { RemoteData } from '../data/remote-data'; import { + DeleteRequest, GetRequest, PostRequest, } from '../data/request.models'; @@ -20,9 +24,12 @@ 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 { NoContent } from '../shared/NoContent.model'; import { getFirstCompletedRemoteData } from '../shared/operators'; +import { sendRequest } from '../shared/request.operators'; import { URLCombiner } from '../url-combiner/url-combiner'; import { AuthStatus } from './models/auth-status.model'; +import { MachineToken } from './models/machine-token.model'; import { ShortLivedToken } from './models/short-lived-token.model'; /** @@ -31,6 +38,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 +147,32 @@ export abstract class AuthRequestService { }), ); } + + /** + * Send a post request to create a machine token + */ + public postToMachineTokenEndpoint(): Observable> { + 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(request.uuid)), + ); + } + + /** + * Send a delete request to destroy a machine token + */ + public deleteToMachineTokenEndpoint(): Observable> { + 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(request.uuid)), + ); + } } diff --git a/src/app/core/auth/auth.service.ts b/src/app/core/auth/auth.service.ts index cd773b68cfa..59aabf25ec8 100644 --- a/src/app/core/auth/auth.service.ts +++ b/src/app/core/auth/auth.service.ts @@ -57,11 +57,13 @@ import { NativeWindowRef, NativeWindowService, } from '../services/window.service'; +import { NoContent } from '../shared/NoContent.model'; import { getAllSucceededRemoteDataPayload, getFirstCompletedRemoteData, } from '../shared/operators'; import { PageInfo } from '../shared/page-info.model'; +import { URLCombiner } from '../url-combiner/url-combiner'; import { CheckAuthenticationTokenAction, RefreshTokenAction, @@ -78,6 +80,7 @@ import { AuthTokenInfo, TOKENITEM, } from './models/auth-token-info.model'; +import { MachineToken } from './models/machine-token.model'; import { getAuthenticatedUserId, getAuthenticationToken, @@ -579,6 +582,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 +692,18 @@ export class AuthService { } } + /** + * Create a new machine token for the current user + */ + public createMachineToken(): Observable> { + return this.authRequestService.postToMachineTokenEndpoint(); + } + + /** + * Delete the machine token for the current user + */ + public deleteMachineToken(): Observable> { + return this.authRequestService.deleteToMachineTokenEndpoint(); + } + } diff --git a/src/app/core/auth/models/auth.method-type.ts b/src/app/core/auth/models/auth.method-type.ts index 594d6d8b395..0a68ae9cad3 100644 --- a/src/app/core/auth/models/auth.method-type.ts +++ b/src/app/core/auth/models/auth.method-type.ts @@ -5,5 +5,5 @@ export enum AuthMethodType { Ip = 'ip', X509 = 'x509', Oidc = 'oidc', - Orcid = 'orcid' + Orcid = 'orcid', } diff --git a/src/app/core/auth/models/auth.registration-type.ts b/src/app/core/auth/models/auth.registration-type.ts new file mode 100644 index 00000000000..b8aaa1fe40d --- /dev/null +++ b/src/app/core/auth/models/auth.registration-type.ts @@ -0,0 +1,4 @@ +export enum AuthRegistrationType { + Orcid = 'ORCID', + Validation = 'VALIDATION_', +} diff --git a/src/app/core/auth/models/machine-token.model.ts b/src/app/core/auth/models/machine-token.model.ts new file mode 100644 index 00000000000..1d146d743a1 --- /dev/null +++ b/src/app/core/auth/models/machine-token.model.ts @@ -0,0 +1,40 @@ +import { + autoserialize, + autoserializeAs, + deserialize, +} from 'cerialize'; + +import { typedObject } from '../../cache/builders/build-decorators'; +import { CacheableObject } from '../../cache/cacheable-object.model'; +import { HALLink } from '../../shared/hal-link.model'; +import { ResourceType } from '../../shared/resource-type'; +import { excludeFromEquals } from '../../utilities/equals.decorators'; +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; + }; +} diff --git a/src/app/core/auth/models/machine-token.resource-type.ts b/src/app/core/auth/models/machine-token.resource-type.ts new file mode 100644 index 00000000000..c3d3dabeb94 --- /dev/null +++ b/src/app/core/auth/models/machine-token.resource-type.ts @@ -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'); diff --git a/src/app/core/data/eperson-registration.service.spec.ts b/src/app/core/data/eperson-registration.service.spec.ts index a60cef121a8..acf9ab284a1 100644 --- a/src/app/core/data/eperson-registration.service.spec.ts +++ b/src/app/core/data/eperson-registration.service.spec.ts @@ -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({ diff --git a/src/app/core/data/eperson-registration.service.ts b/src/app/core/data/eperson-registration.service.ts index 90a3fab83a9..29c110d80e9 100644 --- a/src/app/core/data/eperson-registration.service.ts +++ b/src/app/core/data/eperson-registration.service.ts @@ -3,6 +3,7 @@ import { HttpParams, } from '@angular/common/http'; import { Injectable } from '@angular/core'; +import { Operation } from 'fast-json-patch'; import { Observable } from 'rxjs'; import { filter, @@ -18,6 +19,7 @@ import { RemoteDataBuildService } from '../cache/builders/remote-data-build.serv import { HttpOptions } from '../dspace-rest/dspace-rest.service'; import { GenericConstructor } from '../shared/generic-constructor'; import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { NoContent } from '../shared/NoContent.model'; import { getFirstCompletedRemoteData } from '../shared/operators'; import { Registration } from '../shared/registration.model'; import { ResponseParsingService } from './parsing.service'; @@ -25,6 +27,7 @@ import { RegistrationResponseParsingService } from './registration-response-pars import { RemoteData } from './remote-data'; import { GetRequest, + PatchRequest, PostRequest, } from './request.models'; import { RequestService } from './request.service'; @@ -45,7 +48,6 @@ export class EpersonRegistrationService { protected rdbService: RemoteDataBuildService, protected halService: HALEndpointService, ) { - } /** @@ -103,10 +105,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> { + searchByTokenAndUpdateData(token: string): Observable> { const requestId = this.requestService.generateRequestId(); const href$ = this.getTokenSearchEndpoint(token).pipe( @@ -126,7 +129,11 @@ export class EpersonRegistrationService { return this.rdbService.buildSingle(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 +141,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> { + 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 { + return RegistrationResponseParsingService; + }, + }); + this.requestService.send(request, true); + }); + return this.rdbService.buildSingle(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> { + 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; + } } diff --git a/src/app/core/eperson/eperson-data.service.spec.ts b/src/app/core/eperson/eperson-data.service.spec.ts index 749afb5daa3..ac735d8e924 100644 --- a/src/app/core/eperson/eperson-data.service.spec.ts +++ b/src/app/core/eperson/eperson-data.service.spec.ts @@ -46,6 +46,7 @@ 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 { RemoteData } from '../data/remote-data'; import { PatchRequest, PostRequest, @@ -351,6 +352,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) => { + expect(result.hasSucceeded).toBeTrue(); + }); + expect(service.mergeEPersonDataWithToken).toHaveBeenCalledWith(uuid, token, metadataKey); + }); + }); }); class DummyChangeAnalyzer implements ChangeAnalyzer { diff --git a/src/app/core/eperson/eperson-data.service.ts b/src/app/core/eperson/eperson-data.service.ts index 0de6de7407e..33a02de4d78 100644 --- a/src/app/core/eperson/eperson-data.service.ts +++ b/src/app/core/eperson/eperson-data.service.ts @@ -394,6 +394,32 @@ export class EPersonDataService extends IdentifiableDataService 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> { + 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 diff --git a/src/app/core/shared/registration.model.ts b/src/app/core/shared/registration.model.ts index e2efa6a02c2..90663042fc9 100644 --- a/src/app/core/shared/registration.model.ts +++ b/src/app/core/shared/registration.model.ts @@ -1,11 +1,27 @@ +// eslint-disable-next-line max-classes-per-file +import { AuthRegistrationType } from '../auth/models/auth.registration-type'; import { typedObject } from '../cache/builders/build-decorators'; +import { MetadataValue } from './metadata.models'; import { REGISTRATION } from './registration.resource-type'; import { ResourceType } from './resource-type'; import { UnCacheableObject } from './uncacheable-object.model'; +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; } diff --git a/src/app/external-log-in/decorators/external-log-in.methods-decorator.ts b/src/app/external-log-in/decorators/external-log-in.methods-decorator.ts new file mode 100644 index 00000000000..593dbb3aea8 --- /dev/null +++ b/src/app/external-log-in/decorators/external-log-in.methods-decorator.ts @@ -0,0 +1,34 @@ +import { AuthRegistrationType } from 'src/app/core/auth/models/auth.registration-type'; + +import { OrcidConfirmationComponent } from '../registration-types/orcid-confirmation/orcid-confirmation.component'; + +export type ExternalLoginTypeComponent = + typeof OrcidConfirmationComponent; + +export const LOGIN_METHOD_FOR_DECORATOR_MAP = new Map([ + [AuthRegistrationType.Orcid, OrcidConfirmationComponent], +]); + +/** + * 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; + } + LOGIN_METHOD_FOR_DECORATOR_MAP.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 LOGIN_METHOD_FOR_DECORATOR_MAP.get(authMethodType); +} diff --git a/src/app/external-log-in/decorators/external-login-method-entry.component.ts b/src/app/external-log-in/decorators/external-login-method-entry.component.ts new file mode 100644 index 00000000000..d4854cd4bb8 --- /dev/null +++ b/src/app/external-log-in/decorators/external-login-method-entry.component.ts @@ -0,0 +1,20 @@ +import { Inject } from '@angular/core'; + +import { Registration } from '../../core/shared/registration.model'; + +/** + * This component renders a form to complete the registration process + */ +export abstract class ExternalLoginMethodEntryComponent { + + /** + * The registration data object + */ + public registrationData: Registration; + + protected constructor( + @Inject('registrationDataProvider') protected injectedRegistrationDataObject: Registration, + ) { + this.registrationData = injectedRegistrationDataObject; + } +} diff --git a/src/app/external-log-in/email-confirmation/confirm-email/confirm-email.component.html b/src/app/external-log-in/email-confirmation/confirm-email/confirm-email.component.html new file mode 100644 index 00000000000..455aaf75e73 --- /dev/null +++ b/src/app/external-log-in/email-confirmation/confirm-email/confirm-email.component.html @@ -0,0 +1,37 @@ +

+ {{ "external-login.confirm-email.header" | translate }} +

+ +
+
+ +
+ {{ "external-login.confirmation.email-required" | translate }} +
+
+ {{ "external-login.confirmation.email-invalid" | translate }} +
+
+ + +
diff --git a/src/app/external-log-in/email-confirmation/confirm-email/confirm-email.component.scss b/src/app/external-log-in/email-confirmation/confirm-email/confirm-email.component.scss new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/app/external-log-in/email-confirmation/confirm-email/confirm-email.component.spec.ts b/src/app/external-log-in/email-confirmation/confirm-email/confirm-email.component.spec.ts new file mode 100644 index 00000000000..5aa2b91653b --- /dev/null +++ b/src/app/external-log-in/email-confirmation/confirm-email/confirm-email.component.spec.ts @@ -0,0 +1,177 @@ +import { CommonModule } from '@angular/common'; +import { + EventEmitter, + NO_ERRORS_SCHEMA, +} from '@angular/core'; +import { + ComponentFixture, + TestBed, +} from '@angular/core/testing'; +import { + FormBuilder, + ReactiveFormsModule, +} from '@angular/forms'; +import { By } from '@angular/platform-browser'; +import { + TranslateLoader, + TranslateModule, + TranslateService, +} from '@ngx-translate/core'; +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 { NativeWindowService } from '../../../core/services/window.service'; +import { Registration } from '../../../core/shared/registration.model'; +import { + MockWindow, + NativeWindowMockFactory, +} from '../../../shared/mocks/mock-native-window-ref'; +import { TranslateLoaderMock } from '../../../shared/mocks/translate-loader.mock'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; +import { ExternalLoginService } from '../../services/external-login.service'; +import { ConfirmEmailComponent } from './confirm-email.component'; + +describe('ConfirmEmailComponent', () => { + let component: ConfirmEmailComponent; + let fixture: ComponentFixture; + let externalLoginServiceSpy: jasmine.SpyObj; + let epersonDataServiceSpy: jasmine.SpyObj; + let notificationServiceSpy: jasmine.SpyObj; + let authServiceSpy: jasmine.SpyObj; + 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({ + 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, + ConfirmEmailComponent, + 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(); + }); +}); diff --git a/src/app/external-log-in/email-confirmation/confirm-email/confirm-email.component.ts b/src/app/external-log-in/email-confirmation/confirm-email/confirm-email.component.ts new file mode 100644 index 00000000000..5da1c93705c --- /dev/null +++ b/src/app/external-log-in/email-confirmation/confirm-email/confirm-email.component.ts @@ -0,0 +1,196 @@ +import { NgIf } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + Inject, + Input, + OnDestroy, + OnInit, +} from '@angular/core'; +import { + FormBuilder, + FormGroup, + ReactiveFormsModule, + Validators, +} from '@angular/forms'; +import { + TranslateModule, + 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 { + NativeWindowRef, + NativeWindowService, +} from '../../../core/services/window.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 { ExternalLoginService } from '../../services/external-login.service'; + +@Component({ + selector: 'ds-confirm-email', + templateUrl: './confirm-email.component.html', + styleUrls: ['./confirm-email.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: true, + imports: [ + TranslateModule, + NgIf, + ReactiveFormsModule, + ], +}) +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()); + } +} diff --git a/src/app/external-log-in/email-confirmation/confirmation-sent/confirmation-sent.component.html b/src/app/external-log-in/email-confirmation/confirmation-sent/confirmation-sent.component.html new file mode 100644 index 00000000000..3d79b16a4e2 --- /dev/null +++ b/src/app/external-log-in/email-confirmation/confirmation-sent/confirmation-sent.component.html @@ -0,0 +1,5 @@ +

+ {{ "external-login.confirm-email-sent.header" | translate }} +

+ +

diff --git a/src/app/external-log-in/email-confirmation/confirmation-sent/confirmation-sent.component.scss b/src/app/external-log-in/email-confirmation/confirmation-sent/confirmation-sent.component.scss new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/app/external-log-in/email-confirmation/confirmation-sent/confirmation-sent.component.spec.ts b/src/app/external-log-in/email-confirmation/confirmation-sent/confirmation-sent.component.spec.ts new file mode 100644 index 00000000000..3e960a1b792 --- /dev/null +++ b/src/app/external-log-in/email-confirmation/confirmation-sent/confirmation-sent.component.spec.ts @@ -0,0 +1,73 @@ +import { CommonModule } from '@angular/common'; +import { + CUSTOM_ELEMENTS_SCHEMA, + EventEmitter, +} from '@angular/core'; +import { + ComponentFixture, + TestBed, +} from '@angular/core/testing'; +import { + TranslateLoader, + TranslateModule, + TranslateService, +} from '@ngx-translate/core'; +import { of } from 'rxjs'; + +import { TranslateLoaderMock } from '../../../shared/mocks/translate-loader.mock'; +import { ConfirmationSentComponent } from './confirmation-sent.component'; + +describe('ConfirmationSentComponent', () => { + let component: ConfirmationSentComponent; + let fixture: ComponentFixture; + 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({ + providers: [ + { provide: TranslateService, useValue: translateServiceStub }, + ], + imports: [ + CommonModule, + ConfirmationSentComponent, + 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(); + }); +}); diff --git a/src/app/external-log-in/email-confirmation/confirmation-sent/confirmation-sent.component.ts b/src/app/external-log-in/email-confirmation/confirmation-sent/confirmation-sent.component.ts new file mode 100644 index 00000000000..5d6f2786cf6 --- /dev/null +++ b/src/app/external-log-in/email-confirmation/confirmation-sent/confirmation-sent.component.ts @@ -0,0 +1,16 @@ +import { + ChangeDetectionStrategy, + Component, +} from '@angular/core'; +import { TranslateModule } from '@ngx-translate/core'; + +@Component({ + selector: 'ds-confirmation-sent', + templateUrl: './confirmation-sent.component.html', + styleUrls: ['./confirmation-sent.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [TranslateModule], + standalone: true, + +}) +export class ConfirmationSentComponent { } diff --git a/src/app/external-log-in/email-confirmation/provide-email/provide-email.component.html b/src/app/external-log-in/email-confirmation/provide-email/provide-email.component.html new file mode 100644 index 00000000000..46e804e1c2e --- /dev/null +++ b/src/app/external-log-in/email-confirmation/provide-email/provide-email.component.html @@ -0,0 +1,38 @@ +

+ {{ "external-login.provide-email.header" | translate }} +

+ +
+
+ + +
+ {{ "external-login.confirmation.email-required" | translate }} +
+
+ {{ "external-login.confirmation.email-invalid" | translate }} +
+
+ + +
diff --git a/src/app/external-log-in/email-confirmation/provide-email/provide-email.component.scss b/src/app/external-log-in/email-confirmation/provide-email/provide-email.component.scss new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/app/external-log-in/email-confirmation/provide-email/provide-email.component.spec.ts b/src/app/external-log-in/email-confirmation/provide-email/provide-email.component.spec.ts new file mode 100644 index 00000000000..1e78b5a32ad --- /dev/null +++ b/src/app/external-log-in/email-confirmation/provide-email/provide-email.component.spec.ts @@ -0,0 +1,68 @@ +import { CommonModule } from '@angular/common'; +import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; +import { + ComponentFixture, + TestBed, +} from '@angular/core/testing'; +import { FormBuilder } from '@angular/forms'; +import { + TranslateLoader, + TranslateModule, +} from '@ngx-translate/core'; + +import { TranslateLoaderMock } from '../../../shared/mocks/translate-loader.mock'; +import { ExternalLoginService } from '../../services/external-login.service'; +import { ProvideEmailComponent } from './provide-email.component'; + +describe('ProvideEmailComponent', () => { + let component: ProvideEmailComponent; + let fixture: ComponentFixture; + let externalLoginServiceSpy: jasmine.SpyObj; + + beforeEach(async () => { + const externalLoginService = jasmine.createSpyObj('ExternalLoginService', ['patchUpdateRegistration']); + + await TestBed.configureTestingModule({ + providers: [ + FormBuilder, + { provide: ExternalLoginService, useValue: externalLoginService }, + ], + imports: [ + CommonModule, + ProvideEmailComponent, + 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; + 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'); + // }); +}); diff --git a/src/app/external-log-in/email-confirmation/provide-email/provide-email.component.ts b/src/app/external-log-in/email-confirmation/provide-email/provide-email.component.ts new file mode 100644 index 00000000000..0b88a9c573c --- /dev/null +++ b/src/app/external-log-in/email-confirmation/provide-email/provide-email.component.ts @@ -0,0 +1,75 @@ +import { NgIf } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + Input, + OnDestroy, +} from '@angular/core'; +import { + FormBuilder, + FormGroup, + ReactiveFormsModule, + Validators, +} from '@angular/forms'; +import { TranslateModule } from '@ngx-translate/core'; +import { Subscription } from 'rxjs'; + +import { hasValue } from '../../../shared/empty.util'; +import { ExternalLoginService } from '../../services/external-login.service'; + +@Component({ + selector: 'ds-provide-email', + templateUrl: './provide-email.component.html', + styleUrls: ['./provide-email.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + TranslateModule, + NgIf, + ReactiveFormsModule, + ], + standalone: true, +}) +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()); + } +} diff --git a/src/app/external-log-in/external-log-in/external-log-in.component.html b/src/app/external-log-in/external-log-in/external-log-in.component.html new file mode 100644 index 00000000000..099a37bd629 --- /dev/null +++ b/src/app/external-log-in/external-log-in/external-log-in.component.html @@ -0,0 +1,49 @@ +
+

{{ 'external-login.confirmation.header' | translate}}

+
+
+ + +
+ + {{ informationText }} + +
+
+ + + + + + +
+
+

or

+
+
+ +
+
+ + + + + + diff --git a/src/app/external-log-in/external-log-in/external-log-in.component.scss b/src/app/external-log-in/external-log-in/external-log-in.component.scss new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/app/external-log-in/external-log-in/external-log-in.component.spec.ts b/src/app/external-log-in/external-log-in/external-log-in.component.spec.ts new file mode 100644 index 00000000000..fa4668b524b --- /dev/null +++ b/src/app/external-log-in/external-log-in/external-log-in.component.spec.ts @@ -0,0 +1,119 @@ +import { CommonModule } from '@angular/common'; +import { EventEmitter } from '@angular/core'; +import { + ComponentFixture, + TestBed, +} from '@angular/core/testing'; +import { FormBuilder } from '@angular/forms'; +import { By } from '@angular/platform-browser'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { + TranslateModule, + TranslateService, +} from '@ngx-translate/core'; +import { of as observableOf } from 'rxjs'; + +import { AuthService } from '../../core/auth/auth.service'; +import { AuthRegistrationType } from '../../core/auth/models/auth.registration-type'; +import { MetadataValue } from '../../core/shared/metadata.models'; +import { Registration } from '../../core/shared/registration.model'; +import { AuthServiceMock } from '../../shared/mocks/auth.service.mock'; +import { BrowserOnlyPipe } from '../../shared/utils/browser-only.pipe'; +import { ConfirmEmailComponent } from '../email-confirmation/confirm-email/confirm-email.component'; +import { OrcidConfirmationComponent } from '../registration-types/orcid-confirmation/orcid-confirmation.component'; +import { ExternalLogInComponent } from './external-log-in.component'; + +describe('ExternalLogInComponent', () => { + let component: ExternalLogInComponent; + let fixture: ComponentFixture; + 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({}), BrowserOnlyPipe, ExternalLogInComponent, OrcidConfirmationComponent, BrowserAnimationsModule], + providers: [ + { provide: TranslateService, useValue: translateServiceStub }, + { provide: AuthService, useValue: new AuthServiceMock() }, + { provide: NgbModal, useValue: modalService }, + FormBuilder, + ], + }) + .overrideComponent(ExternalLogInComponent, { + remove: { + imports: [ConfirmEmailComponent], + }, + }) + .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'); + }); +}); + + diff --git a/src/app/external-log-in/external-log-in/external-log-in.component.ts b/src/app/external-log-in/external-log-in/external-log-in.component.ts new file mode 100644 index 00000000000..25bac19d851 --- /dev/null +++ b/src/app/external-log-in/external-log-in/external-log-in.component.ts @@ -0,0 +1,187 @@ +import { + NgComponentOutlet, + NgIf, +} from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + Injector, + Input, + OnDestroy, + OnInit, +} from '@angular/core'; +import { + NgbModal, + NgbModalRef, +} from '@ng-bootstrap/ng-bootstrap'; +import { + TranslateModule, + 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 { AlertComponent } from '../../shared/alert/alert.component'; +import { AlertType } from '../../shared/alert/alert-type'; +import { + hasValue, + isEmpty, +} from '../../shared/empty.util'; +import { ThemedLogInComponent } from '../../shared/log-in/themed-log-in.component'; +import { + ExternalLoginTypeComponent, + getExternalLoginConfirmationType, +} from '../decorators/external-log-in.methods-decorator'; +import { ConfirmEmailComponent } from '../email-confirmation/confirm-email/confirm-email.component'; +import { ProvideEmailComponent } from '../email-confirmation/provide-email/provide-email.component'; + +@Component({ + selector: 'ds-external-log-in', + templateUrl: './external-log-in.component.html', + styleUrls: ['./external-log-in.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + ProvideEmailComponent, + AlertComponent, + TranslateModule, + ConfirmEmailComponent, + ThemedLogInComponent, + NgIf, + NgComponentOutlet, + ], + standalone: true, +}) +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(): ExternalLoginTypeComponent { + 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(); + } +} diff --git a/src/app/external-log-in/guards/registration-token-guard.ts b/src/app/external-log-in/guards/registration-token-guard.ts new file mode 100644 index 00000000000..df49c712c6a --- /dev/null +++ b/src/app/external-log-in/guards/registration-token-guard.ts @@ -0,0 +1,53 @@ +import { inject } from '@angular/core'; +import { + ActivatedRouteSnapshot, + CanActivateFn, + Router, + RouterStateSnapshot, +} from '@angular/router'; +import { + map, + Observable, + of, +} from 'rxjs'; + +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'; + +/** + * 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. + * @param epersonRegistrationService - The eperson registration service. + * @param router - The router. + * @returns A value indicating if the user can activate the route. + */ +export const registrationTokenGuard: CanActivateFn = ( + route: ActivatedRouteSnapshot, + state: RouterStateSnapshot, +): Observable => { + const epersonRegistrationService = inject(EpersonRegistrationService); + const router = inject(Router); + if (route.params.token) { + return epersonRegistrationService + .searchByTokenAndHandleError(route.params.token) + .pipe( + getFirstCompletedRemoteData(), + map( + (data: RemoteData) => { + if (data.hasSucceeded && hasValue(data)) { + return true; + } else { + router.navigate(['/404']); + } + }, + ), + ); + } else { + router.navigate(['/404']); + return of(false); + } +}; diff --git a/src/app/external-log-in/guards/registration-token.guard.spec.ts b/src/app/external-log-in/guards/registration-token.guard.spec.ts new file mode 100644 index 00000000000..940d6a8d663 --- /dev/null +++ b/src/app/external-log-in/guards/registration-token.guard.spec.ts @@ -0,0 +1,101 @@ +import { + fakeAsync, + TestBed, + tick, +} from '@angular/core/testing'; +import { + ActivatedRoute, + Router, + RouterStateSnapshot, +} from '@angular/router'; +import { + Observable, + of as observableOf, +} from 'rxjs'; + +import { AuthService } from '../../core/auth/auth.service'; +import { EpersonRegistrationService } from '../../core/data/eperson-registration.service'; +import { EPerson } from '../../core/eperson/models/eperson.model'; +import { Registration } from '../../core/shared/registration.model'; +import { RouterMock } from '../../shared/mocks/router.mock'; +import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; +import { registrationTokenGuard } from './registration-token-guard'; + +describe('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', + }); + + let arouteStub = { + snapshot: { + params: { + token: '123456789', + }, + }, + }; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [{ provide: Router, useValue: route }, + { + provide: ActivatedRoute, + useValue: arouteStub, + }, + { provide: EpersonRegistrationService, useValue: epersonRegistrationService }, + { provide: AuthService, useValue: authService }, + ], + }); + }); + + describe('when token provided', () => { + it('can activate must return true when registration data includes groups', fakeAsync(() => { + const activatedRoute = TestBed.inject(ActivatedRoute); + + const result$ = TestBed.runInInjectionContext(() => { + return registrationTokenGuard(activatedRoute.snapshot, {} as RouterStateSnapshot) as Observable; + }); + + let output = null; + result$.subscribe((result) => (output = result)); + tick(100); + expect(output).toBeTrue(); + })); + }); + + describe('when no token provided', () => { + it('can activate must return false when registration data includes groups', fakeAsync(() => { + const registrationWithDifferentUserFromLoggedIn = Object.assign(new Registration(), { + email: 't1@email.org', + token: 'test-token', + }); + epersonRegistrationService.searchByTokenAndHandleError.and.returnValue(observableOf(registrationWithDifferentUserFromLoggedIn)); + let activatedRoute = TestBed.inject(ActivatedRoute); + activatedRoute.snapshot.params.token = null; + + const result$ = TestBed.runInInjectionContext(() => { + return registrationTokenGuard(activatedRoute.snapshot, {} as RouterStateSnapshot) as Observable; + }); + + let output = null; + result$.subscribe((result) => (output = result)); + expect(output).toBeFalse(); + })); + }); + }); diff --git a/src/app/external-log-in/models/registration-data.mock.model.ts b/src/app/external-log-in/models/registration-data.mock.model.ts new file mode 100644 index 00000000000..43efe5a0f31 --- /dev/null +++ b/src/app/external-log-in/models/registration-data.mock.model.ts @@ -0,0 +1,45 @@ +import { AuthMethodType } from '../../core/auth/models/auth.method-type'; +import { MetadataValue } from '../../core/shared/metadata.models'; +import { Registration } from '../../core/shared/registration.model'; + +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', + }, + ], + }, + }, +); diff --git a/src/app/external-log-in/registration-types/orcid-confirmation/orcid-confirmation.component.html b/src/app/external-log-in/registration-types/orcid-confirmation/orcid-confirmation.component.html new file mode 100644 index 00000000000..c731b9e5455 --- /dev/null +++ b/src/app/external-log-in/registration-types/orcid-confirmation/orcid-confirmation.component.html @@ -0,0 +1,35 @@ + diff --git a/src/app/external-log-in/registration-types/orcid-confirmation/orcid-confirmation.component.scss b/src/app/external-log-in/registration-types/orcid-confirmation/orcid-confirmation.component.scss new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/app/external-log-in/registration-types/orcid-confirmation/orcid-confirmation.component.spec.ts b/src/app/external-log-in/registration-types/orcid-confirmation/orcid-confirmation.component.spec.ts new file mode 100644 index 00000000000..83285ddefdf --- /dev/null +++ b/src/app/external-log-in/registration-types/orcid-confirmation/orcid-confirmation.component.spec.ts @@ -0,0 +1,76 @@ +import { CommonModule } from '@angular/common'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { + ComponentFixture, + TestBed, +} from '@angular/core/testing'; +import { + FormBuilder, + FormGroup, +} from '@angular/forms'; +import { + TranslateLoader, + TranslateModule, +} from '@ngx-translate/core'; +import { Registration } from 'src/app/core/shared/registration.model'; + +import { TranslateLoaderMock } from '../../../shared/mocks/translate-loader.mock'; +import { BrowserOnlyMockPipe } from '../../../shared/testing/browser-only-mock.pipe'; +import { mockRegistrationDataModel } from '../../models/registration-data.mock.model'; +import { OrcidConfirmationComponent } from './orcid-confirmation.component'; + +describe('OrcidConfirmationComponent', () => { + let component: OrcidConfirmationComponent; + let fixture: ComponentFixture; + let model: Registration; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + providers: [ + FormBuilder, + { provide: 'registrationDataProvider', useValue: mockRegistrationDataModel }, + ], + imports: [ + OrcidConfirmationComponent, + CommonModule, + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock, + }, + }), + BrowserOnlyMockPipe, + ], + 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.registrationData.email = null; + component.ngOnInit(); + fixture.detectChanges(); + const emailFormControl = component.form.get('email'); + expect(emailFormControl.value).toBe(''); + }); + +}); diff --git a/src/app/external-log-in/registration-types/orcid-confirmation/orcid-confirmation.component.ts b/src/app/external-log-in/registration-types/orcid-confirmation/orcid-confirmation.component.ts new file mode 100644 index 00000000000..909f17c2df1 --- /dev/null +++ b/src/app/external-log-in/registration-types/orcid-confirmation/orcid-confirmation.component.ts @@ -0,0 +1,77 @@ +import { NgIf } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + Inject, + OnInit, +} from '@angular/core'; +import { + FormBuilder, + FormGroup, + ReactiveFormsModule, +} from '@angular/forms'; +import { TranslateModule } from '@ngx-translate/core'; + +import { Registration } from '../../../core/shared/registration.model'; +import { BrowserOnlyPipe } from '../../../shared/utils/browser-only.pipe'; +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, + imports: [ + ReactiveFormsModule, + TranslateModule, + BrowserOnlyPipe, + NgIf, + ], + standalone: true, +}) +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.registrationData.netId, disabled: true }], + firstname: [{ value: this.getFirstname(), disabled: true }], + lastname: [{ value: this.getLastname(), disabled: true }], + email: [{ value: this.registrationData?.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.registrationData.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.registrationData.registrationMetadata?.['eperson.lastname']?.[0]?.value || ''; + } +} diff --git a/src/app/external-log-in/resolvers/registration-data.resolver.spec.ts b/src/app/external-log-in/resolvers/registration-data.resolver.spec.ts new file mode 100644 index 00000000000..58b593c8885 --- /dev/null +++ b/src/app/external-log-in/resolvers/registration-data.resolver.spec.ts @@ -0,0 +1,52 @@ +import { TestBed } from '@angular/core/testing'; +import { + ActivatedRouteSnapshot, + RouterStateSnapshot, +} from '@angular/router'; + +import { EpersonRegistrationService } from '../../core/data/eperson-registration.service'; +import { Registration } from '../../core/shared/registration.model'; +import { + createSuccessfulRemoteDataObject, + createSuccessfulRemoteDataObject$, +} from '../../shared/remote-data.utils'; +import { RegistrationDataResolver } from './registration-data.resolver'; + +describe('RegistrationDataResolver', () => { + let resolver: RegistrationDataResolver; + let epersonRegistrationServiceSpy: jasmine.SpyObj; + 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; + }); + + 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); + }); +}); diff --git a/src/app/external-log-in/resolvers/registration-data.resolver.ts b/src/app/external-log-in/resolvers/registration-data.resolver.ts new file mode 100644 index 00000000000..23931c22f15 --- /dev/null +++ b/src/app/external-log-in/resolvers/registration-data.resolver.ts @@ -0,0 +1,43 @@ +import { Injectable } from '@angular/core'; +import { + ActivatedRouteSnapshot, + Resolve, + RouterStateSnapshot, +} from '@angular/router'; +import { Observable } from 'rxjs'; + +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', +}) +/** + * Resolver for retrieving registration data based on a token. + */ +export class RegistrationDataResolver implements Resolve> { + + /** + * 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> { + const token = route.params.token; + if (hasValue(token)) { + return this.epersonRegistrationService.searchByTokenAndHandleError(token).pipe( + getFirstCompletedRemoteData(), + ); + } + } +} diff --git a/src/app/external-log-in/services/external-login.service.spec.ts b/src/app/external-log-in/services/external-login.service.spec.ts new file mode 100644 index 00000000000..ca0a54bb4b4 --- /dev/null +++ b/src/app/external-log-in/services/external-login.service.spec.ts @@ -0,0 +1,92 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { + fakeAsync, + TestBed, + tick, +} from '@angular/core/testing'; +import { Router } from '@angular/router'; +import { provideMockStore } from '@ngrx/store/testing'; +import { TranslateService } from '@ngx-translate/core'; +import { getTestScheduler } from 'jasmine-marbles'; +import { of as observableOf } from 'rxjs'; +import { TestScheduler } from 'rxjs/testing'; + +import { EpersonRegistrationService } from '../../core/data/eperson-registration.service'; +import { RemoteData } from '../../core/data/remote-data'; +import { NoContent } from '../../core/shared/NoContent.model'; +import { Registration } from '../../core/shared/registration.model'; +import { RouterMock } from '../../shared/mocks/router.mock'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { + createFailedRemoteDataObject, + createSuccessfulRemoteDataObject$, +} from '../../shared/remote-data.utils'; +import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub'; +import { ExternalLoginService } from './external-login.service'; + +describe('ExternalLoginService', () => { + let service: ExternalLoginService; + let epersonRegistrationService; + let router: any; + let notificationService; + let translate; + let scheduler: TestScheduler; + + 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 }, + provideMockStore(), + ], + schemas: [NO_ERRORS_SCHEMA], + }); + service = TestBed.inject(ExternalLoginService); + scheduler = getTestScheduler(); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should call epersonRegistrationService.patchUpdateRegistration with the correct parameters', () => { + epersonRegistrationService.patchUpdateRegistration.and.returnValue(observableOf({} as RemoteData)); + 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', () => { + epersonRegistrationService.patchUpdateRegistration.and.returnValue(createSuccessfulRemoteDataObject$(new Registration())); + scheduler.schedule(() => service.patchUpdateRegistration(values, field, registrationId, token, operation).subscribe()); + scheduler.flush(); + expect((router as any).navigate).toHaveBeenCalledWith(['/email-confirmation']); + }); + + it('should show an error notification if the remote data has failed', fakeAsync(() => { + const remoteData = createFailedRemoteDataObject('error message'); + epersonRegistrationService.patchUpdateRegistration.and.returnValue(observableOf(remoteData)); + translate.get.and.returnValue(observableOf('error message')); + + let result = null; + service.patchUpdateRegistration(values, field, registrationId, token, operation).subscribe((data) => (result = data)); + tick(100); + expect(result).toEqual(remoteData); + expect(notificationService.error).toHaveBeenCalled(); + })); +}); diff --git a/src/app/external-log-in/services/external-login.service.ts b/src/app/external-log-in/services/external-login.service.ts new file mode 100644 index 00000000000..fb9f9fd66fb --- /dev/null +++ b/src/app/external-log-in/services/external-login.service.ts @@ -0,0 +1,73 @@ +import { Injectable } from '@angular/core'; +import { Router } from '@angular/router'; +import { + select, + Store, +} from '@ngrx/store'; +import { TranslateService } from '@ngx-translate/core'; +import { + filter, + map, + Observable, +} from 'rxjs'; +import { AuthMethod } from 'src/app/core/auth/models/auth.method'; +import { getAuthenticationMethods } from 'src/app/core/auth/selectors'; +import { CoreState } from 'src/app/core/core-state.model'; + +import { EpersonRegistrationService } from '../../core/data/eperson-registration.service'; +import { RemoteData } from '../../core/data/remote-data'; +import { NoContent } from '../../core/shared/NoContent.model'; +import { getFirstCompletedRemoteData } from '../../core/shared/operators'; +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, + ) { } + + /** + * 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> { + 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 { + return this.store.pipe( + select(getAuthenticationMethods), + filter((methods: AuthMethod[]) => methods.length > 0), + map((methods: AuthMethod[]) => methods.find((m: AuthMethod) => m.authMethodType.toString() === registrationType.toLocaleLowerCase()).location), + ); + } +} diff --git a/src/app/external-login-email-confirmation-page/external-login-email-confirmation-page-routes.ts b/src/app/external-login-email-confirmation-page/external-login-email-confirmation-page-routes.ts new file mode 100644 index 00000000000..4424357e9c3 --- /dev/null +++ b/src/app/external-login-email-confirmation-page/external-login-email-confirmation-page-routes.ts @@ -0,0 +1,11 @@ +import { Routes } from '@angular/router'; + +import { ExternalLoginEmailConfirmationPageComponent } from './external-login-email-confirmation-page.component'; + +export const ROUTES: Routes = [ + { + path: '', + pathMatch: 'full', + component: ExternalLoginEmailConfirmationPageComponent, + }, +]; diff --git a/src/app/external-login-email-confirmation-page/external-login-email-confirmation-page.component.html b/src/app/external-login-email-confirmation-page/external-login-email-confirmation-page.component.html new file mode 100644 index 00000000000..c1cf46032b4 --- /dev/null +++ b/src/app/external-login-email-confirmation-page/external-login-email-confirmation-page.component.html @@ -0,0 +1,3 @@ +
+ +
diff --git a/src/app/external-login-email-confirmation-page/external-login-email-confirmation-page.component.scss b/src/app/external-login-email-confirmation-page/external-login-email-confirmation-page.component.scss new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/app/external-login-email-confirmation-page/external-login-email-confirmation-page.component.spec.ts b/src/app/external-login-email-confirmation-page/external-login-email-confirmation-page.component.spec.ts new file mode 100644 index 00000000000..cee29d01c38 --- /dev/null +++ b/src/app/external-login-email-confirmation-page/external-login-email-confirmation-page.component.spec.ts @@ -0,0 +1,48 @@ +import { + ComponentFixture, + TestBed, +} from '@angular/core/testing'; +import { + TranslateLoader, + TranslateModule, +} from '@ngx-translate/core'; + +import { ConfirmationSentComponent } from '../external-log-in/email-confirmation/confirmation-sent/confirmation-sent.component'; +import { TranslateLoaderMock } from '../shared/mocks/translate-loader.mock'; +import { ExternalLoginEmailConfirmationPageComponent } from './external-login-email-confirmation-page.component'; + +describe('ExternalLoginEmailConfirmationPageComponent', () => { + let component: ExternalLoginEmailConfirmationPageComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock, + }, + }), + ExternalLoginEmailConfirmationPageComponent, + ConfirmationSentComponent, + ], + }) + .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(); + }); +}); diff --git a/src/app/external-login-email-confirmation-page/external-login-email-confirmation-page.component.ts b/src/app/external-login-email-confirmation-page/external-login-email-confirmation-page.component.ts new file mode 100644 index 00000000000..915242828ea --- /dev/null +++ b/src/app/external-login-email-confirmation-page/external-login-email-confirmation-page.component.ts @@ -0,0 +1,12 @@ +import { Component } from '@angular/core'; + +import { ConfirmationSentComponent } from '../external-log-in/email-confirmation/confirmation-sent/confirmation-sent.component'; + +@Component({ + templateUrl: './external-login-email-confirmation-page.component.html', + styleUrls: ['./external-login-email-confirmation-page.component.scss'], + standalone: true, + imports: [ConfirmationSentComponent], +}) +export class ExternalLoginEmailConfirmationPageComponent { +} diff --git a/src/app/external-login-page/external-login-page.component.html b/src/app/external-login-page/external-login-page.component.html new file mode 100644 index 00000000000..755896211de --- /dev/null +++ b/src/app/external-login-page/external-login-page.component.html @@ -0,0 +1,11 @@ +
+ + + + + +
diff --git a/src/app/external-login-page/external-login-page.component.scss b/src/app/external-login-page/external-login-page.component.scss new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/app/external-login-page/external-login-page.component.spec.ts b/src/app/external-login-page/external-login-page.component.spec.ts new file mode 100644 index 00000000000..6f5c792bb88 --- /dev/null +++ b/src/app/external-login-page/external-login-page.component.spec.ts @@ -0,0 +1,91 @@ +import { CommonModule } from '@angular/common'; +import { + ComponentFixture, + TestBed, +} from '@angular/core/testing'; +import { ActivatedRoute } from '@angular/router'; +import { + TranslateLoader, + TranslateModule, +} from '@ngx-translate/core'; +import { of } from 'rxjs'; + +import { Registration } from '../core/shared/registration.model'; +import { ExternalLogInComponent } from '../external-log-in/external-log-in/external-log-in.component'; +import { TranslateLoaderMock } from '../shared/mocks/translate-loader.mock'; +import { ExternalLoginPageComponent } from './external-login-page.component'; + +describe('ExternalLoginPageComponent', () => { + let component: ExternalLoginPageComponent; + let fixture: ComponentFixture; + + 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({ + providers: [ + { + provide: ActivatedRoute, + useValue: { + snapshot: { + params: { + token: '1234567890', + }, + }, + data: of(registrationDataMock), + }, + }, + ], + imports: [ + CommonModule, + ExternalLoginPageComponent, + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock, + }, + }), + ], + }) + .overrideComponent(ExternalLoginPageComponent, { + remove: { + imports: [ExternalLogInComponent], + }, + }) + .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(); + }); +}); diff --git a/src/app/external-login-page/external-login-page.component.ts b/src/app/external-login-page/external-login-page.component.ts new file mode 100644 index 00000000000..5a656159fde --- /dev/null +++ b/src/app/external-login-page/external-login-page.component.ts @@ -0,0 +1,72 @@ +import { + AsyncPipe, + NgIf, +} from '@angular/common'; +import { + Component, + OnInit, +} from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { TranslateModule } from '@ngx-translate/core'; +import { + first, + map, + Observable, + tap, +} from 'rxjs'; + +import { RemoteData } from '../core/data/remote-data'; +import { Registration } from '../core/shared/registration.model'; +import { ExternalLogInComponent } from '../external-log-in/external-log-in/external-log-in.component'; +import { AlertComponent } from '../shared/alert/alert.component'; +import { AlertType } from '../shared/alert/alert-type'; +import { hasNoValue } from '../shared/empty.util'; + +@Component({ + templateUrl: './external-login-page.component.html', + styleUrls: ['./external-login-page.component.scss'], + imports: [ + TranslateModule, + AsyncPipe, + NgIf, + ExternalLogInComponent, + AlertComponent, + ], + standalone: true, +}) +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; + /** + * 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).hasFailed), + map((data) => (data.registrationData as RemoteData).payload), + ); + } +} diff --git a/src/app/external-login-page/external-login-routes.ts b/src/app/external-login-page/external-login-routes.ts new file mode 100644 index 00000000000..c2302d83da3 --- /dev/null +++ b/src/app/external-login-page/external-login-routes.ts @@ -0,0 +1,15 @@ +import { Route } from '@angular/router'; + +import { registrationTokenGuard } from '../external-log-in/guards/registration-token-guard'; +import { RegistrationDataResolver } from '../external-log-in/resolvers/registration-data.resolver'; +import { ThemedExternalLoginPageComponent } from './themed-external-login-page.component'; + +export const ROUTES: Route[] = [ + { + path: '', + pathMatch: 'full', + component: ThemedExternalLoginPageComponent, + canActivate: [registrationTokenGuard], + resolve: { registrationData: RegistrationDataResolver }, + }, +]; diff --git a/src/app/external-login-page/themed-external-login-page.component.ts b/src/app/external-login-page/themed-external-login-page.component.ts new file mode 100644 index 00000000000..320930ea87d --- /dev/null +++ b/src/app/external-login-page/themed-external-login-page.component.ts @@ -0,0 +1,28 @@ +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-external-login-page', + styleUrls: [], + templateUrl: './../shared/theme-support/themed.component.html', + standalone: true, + imports: [ExternalLoginPageComponent], +}) +export class ThemedExternalLoginPageComponent extends ThemedComponent { + protected getComponentName(): string { + return 'ExternalLoginPageComponent'; + } + + protected importThemedComponent(themeName: string): Promise { + return import(`../../themes/${themeName}/app/external-login-page/external-login-page.component`); + } + + protected importUnthemedComponent(): Promise { + return import(`./external-login-page.component`); + } +} diff --git a/src/app/external-login-review-account-info-page/external-login-review-account-info-page-routes.ts b/src/app/external-login-review-account-info-page/external-login-review-account-info-page-routes.ts new file mode 100644 index 00000000000..70fb930b77f --- /dev/null +++ b/src/app/external-login-review-account-info-page/external-login-review-account-info-page-routes.ts @@ -0,0 +1,15 @@ +import { Route } from '@angular/router'; + +import { RegistrationDataResolver } from '../external-log-in/resolvers/registration-data.resolver'; +import { ReviewAccountGuard } from './helpers/review-account.guard'; +import { ThemedExternalLoginReviewAccountInfoPageComponent } from './themed-external-login-review-account-info-page.component'; + +export const ROUTES: Route[] = [ + { + path: '', + pathMatch: 'full', + component: ThemedExternalLoginReviewAccountInfoPageComponent, + canActivate: [ReviewAccountGuard], + resolve: { registrationData: RegistrationDataResolver }, + }, +]; diff --git a/src/app/external-login-review-account-info-page/external-login-review-account-info-page.component.html b/src/app/external-login-review-account-info-page/external-login-review-account-info-page.component.html new file mode 100644 index 00000000000..831b53ce714 --- /dev/null +++ b/src/app/external-login-review-account-info-page/external-login-review-account-info-page.component.html @@ -0,0 +1,13 @@ +
+ + + + +
diff --git a/src/app/external-login-review-account-info-page/external-login-review-account-info-page.component.scss b/src/app/external-login-review-account-info-page/external-login-review-account-info-page.component.scss new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/app/external-login-review-account-info-page/external-login-review-account-info-page.component.spec.ts b/src/app/external-login-review-account-info-page/external-login-review-account-info-page.component.spec.ts new file mode 100644 index 00000000000..4cb44611596 --- /dev/null +++ b/src/app/external-login-review-account-info-page/external-login-review-account-info-page.component.spec.ts @@ -0,0 +1,76 @@ +import { + ComponentFixture, + TestBed, +} from '@angular/core/testing'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { ActivatedRoute } from '@angular/router'; +import { TranslateModule } from '@ngx-translate/core'; +import { of } from 'rxjs'; + +import { mockRegistrationDataModel } from '../external-log-in/models/registration-data.mock.model'; +import { ExternalLoginReviewAccountInfoPageComponent } from './external-login-review-account-info-page.component'; +import { ReviewAccountInfoComponent } from './review-account-info/review-account-info.component'; + +describe('ExternalLoginReviewAccountInfoPageComponent', () => { + let component: ExternalLoginReviewAccountInfoPageComponent; + let fixture: ComponentFixture; + + const mockActivatedRoute = { + snapshot: { + params: { + token: '1234567890', + }, + }, + data: of({ + registrationData: mockRegistrationDataModel, + }), + }; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + providers: [ + { provide: ActivatedRoute, useValue: mockActivatedRoute }, + ], + imports: [ + ExternalLoginReviewAccountInfoPageComponent, + BrowserAnimationsModule, + TranslateModule.forRoot({}), + ], + }) + .overrideComponent(ExternalLoginReviewAccountInfoPageComponent, { + remove: { + imports: [ReviewAccountInfoComponent], + }, + }) + .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(); + }); +}); diff --git a/src/app/external-login-review-account-info-page/external-login-review-account-info-page.component.ts b/src/app/external-login-review-account-info-page/external-login-review-account-info-page.component.ts new file mode 100644 index 00000000000..cc8ba6aa554 --- /dev/null +++ b/src/app/external-login-review-account-info-page/external-login-review-account-info-page.component.ts @@ -0,0 +1,69 @@ +import { + AsyncPipe, + NgIf, +} from '@angular/common'; +import { + Component, + OnInit, +} from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { + first, + map, + Observable, + tap, +} from 'rxjs'; + +import { RemoteData } from '../core/data/remote-data'; +import { Registration } from '../core/shared/registration.model'; +import { AlertComponent } from '../shared/alert/alert.component'; +import { AlertType } from '../shared/alert/alert-type'; +import { hasNoValue } from '../shared/empty.util'; +import { ReviewAccountInfoComponent } from './review-account-info/review-account-info.component'; + +@Component({ + templateUrl: './external-login-review-account-info-page.component.html', + styleUrls: ['./external-login-review-account-info-page.component.scss'], + imports: [ + ReviewAccountInfoComponent, + AsyncPipe, + NgIf, + AlertComponent, + ], + standalone: true, +}) +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; + /** + * 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).hasFailed), + map((data) => (data.registrationData as RemoteData).payload)); + } +} + diff --git a/src/app/external-login-review-account-info-page/helpers/compare-values.pipe.ts b/src/app/external-login-review-account-info-page/helpers/compare-values.pipe.ts new file mode 100644 index 00000000000..a0256a895d8 --- /dev/null +++ b/src/app/external-login-review-account-info-page/helpers/compare-values.pipe.ts @@ -0,0 +1,26 @@ +import { + Pipe, + PipeTransform, +} from '@angular/core'; + +@Pipe({ + name: 'dsCompareValues', + standalone: true, +}) +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 ''; + } else { + return currentValue; + } + } +} diff --git a/src/app/external-login-review-account-info-page/helpers/review-account.guard.spec.ts b/src/app/external-login-review-account-info-page/helpers/review-account.guard.spec.ts new file mode 100644 index 00000000000..1da628e830f --- /dev/null +++ b/src/app/external-login-review-account-info-page/helpers/review-account.guard.spec.ts @@ -0,0 +1,121 @@ +import { + fakeAsync, + TestBed, + tick, +} from '@angular/core/testing'; +import { + ActivatedRoute, + convertToParamMap, + Params, + Router, + RouterStateSnapshot, +} from '@angular/router'; +import { + Observable, + of as observableOf, + of, +} 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 { Registration } from '../../core/shared/registration.model'; +import { RouterMock } from '../../shared/mocks/router.mock'; +import { + createFailedRemoteDataObject$, + createSuccessfulRemoteDataObject$, +} from '../../shared/remote-data.utils'; +import { ReviewAccountGuard } from './review-account.guard'; + +describe('ReviewAccountGuard', () => { + let epersonRegistrationService: any; + let authService: any; + let router: any; + + const registrationMock = Object.assign(new Registration(), { + email: 'test@email.org', + registrationType: AuthRegistrationType.Validation, + }); + + beforeEach(() => { + const paramObject: Params = { token: '1234' }; + epersonRegistrationService = jasmine.createSpyObj('epersonRegistrationService', { + searchByTokenAndHandleError: createSuccessfulRemoteDataObject$(registrationMock), + }); + authService = { + isAuthenticated: () => observableOf(true), + } as any; + router = new RouterMock(); + + TestBed.configureTestingModule({ + providers: [ + { provide: Router, useValue: router }, + { + provide: ActivatedRoute, + useValue: { + queryParamMap: observableOf(convertToParamMap(paramObject)), + snapshot: { + params: { + token: '1234', + }, + }, + }, + }, + { provide: EpersonRegistrationService, useValue: epersonRegistrationService }, + { provide: AuthService, useValue: authService }, + ], + }); + }); + + + it('should return true when registration type is validation', fakeAsync(() => { + const state = {} as RouterStateSnapshot; + const activatedRoute = TestBed.inject(ActivatedRoute); + + const result$ = TestBed.runInInjectionContext(()=> { + return ReviewAccountGuard(activatedRoute.snapshot, state) as Observable; + }); + + let output = null; + result$.subscribe((result) => (output = result)); + tick(100); + expect(output).toBeTrue(); + })); + + + it('should navigate to 404 if the registration search fails', fakeAsync(() => { + const state = {} as RouterStateSnapshot; + const activatedRoute = TestBed.inject(ActivatedRoute); + epersonRegistrationService.searchByTokenAndHandleError.and.returnValue(createFailedRemoteDataObject$()); + + const result$ = TestBed.runInInjectionContext(() => { + return ReviewAccountGuard(activatedRoute.snapshot, state) as Observable; + }); + + let output = null; + result$.subscribe((result) => (output = result)); + tick(100); + expect(output).toBeFalse(); + expect(router.navigate).toHaveBeenCalledWith(['/404']); + })); + + + + it('should navigate to 404 if the registration type is not validation and the user is not authenticated', fakeAsync(() => { + registrationMock.registrationType = AuthRegistrationType.Orcid; + epersonRegistrationService.searchByTokenAndHandleError.and.returnValue(createSuccessfulRemoteDataObject$(registrationMock)); + spyOn(authService, 'isAuthenticated').and.returnValue(of(false)); + const activatedRoute = TestBed.inject(ActivatedRoute); + + const result$ = TestBed.runInInjectionContext(() => { + return ReviewAccountGuard(activatedRoute.snapshot, {} as RouterStateSnapshot) as Observable; + }); + + let output = null; + result$.subscribe((result) => (output = result)); + tick(100); + expect(output).toBeFalse(); + expect(router.navigate).toHaveBeenCalledWith(['/404']); + })); +}); + diff --git a/src/app/external-login-review-account-info-page/helpers/review-account.guard.ts b/src/app/external-login-review-account-info-page/helpers/review-account.guard.ts new file mode 100644 index 00000000000..7d175799c20 --- /dev/null +++ b/src/app/external-login-review-account-info-page/helpers/review-account.guard.ts @@ -0,0 +1,69 @@ +import { inject } from '@angular/core'; +import { + ActivatedRouteSnapshot, + CanActivateFn, + 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'; + +/** + * Determines if a user can activate a route based on the registration token.z + * @param route - The activated route snapshot. + * @param state - The router state snapshot. + * @returns A value indicating if the user can activate the route. + */ +export const ReviewAccountGuard: CanActivateFn = ( + route: ActivatedRouteSnapshot, + state: RouterStateSnapshot, +): Observable => { + const authService = inject(AuthService); + const router = inject(Router); + const epersonRegistrationService = inject(EpersonRegistrationService); + if (route.params.token) { + return epersonRegistrationService + .searchByTokenAndHandleError(route.params.token) + .pipe( + getFirstCompletedRemoteData(), + mergeMap( + (data: RemoteData) => { + 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 authService.isAuthenticated(); + } + } + return of(false); + }, + ), + tap((isValid: boolean) => { + if (!isValid) { + router.navigate(['/404']); + } + }), + catchError(() => { + router.navigate(['/404']); + return of(false); + }), + ); + } else { + router.navigate(['/404']); + return of(false); + } +}; diff --git a/src/app/external-login-review-account-info-page/review-account-info/review-account-info.component.html b/src/app/external-login-review-account-info-page/review-account-info/review-account-info.component.html new file mode 100644 index 00000000000..75ed110a647 --- /dev/null +++ b/src/app/external-login-review-account-info-page/review-account-info/review-account-info.component.html @@ -0,0 +1,59 @@ +

{{'external-login-validation.review-account-info.header' | translate}}

+ + +
+ + + + + + + + + + + + + + + + + + + + + + + +
+ {{ 'external-login-validation.review-account-info.table.header.information' | translate }} + + {{'external-login-validation.review-account-info.table.header.received-value' | translate }} + + {{'external-login-validation.review-account-info.table.header.current-value' | translate }} + {{'external-login-validation.review-account-info.table.header.action' | translate }}
{{ registrationData.registrationType }}{{ registrationData.netId }} + + {{ notApplicableText }} + +
{{ data.label | titlecase }}{{ data.receivedValue }} + + + + +
+
+ +
+
diff --git a/src/app/external-login-review-account-info-page/review-account-info/review-account-info.component.scss b/src/app/external-login-review-account-info-page/review-account-info/review-account-info.component.scss new file mode 100644 index 00000000000..1e531f0d8b9 --- /dev/null +++ b/src/app/external-login-review-account-info-page/review-account-info/review-account-info.component.scss @@ -0,0 +1,13 @@ +:host { + table { + tbody { + background-color: #f7f8f9; + } + + td, + th { + height: 60px; + vertical-align: middle; + } + } +} diff --git a/src/app/external-login-review-account-info-page/review-account-info/review-account-info.component.spec.ts b/src/app/external-login-review-account-info-page/review-account-info/review-account-info.component.spec.ts new file mode 100644 index 00000000000..e7e351fee81 --- /dev/null +++ b/src/app/external-login-review-account-info-page/review-account-info/review-account-info.component.spec.ts @@ -0,0 +1,245 @@ +import { CommonModule } from '@angular/common'; +import { EventEmitter } from '@angular/core'; +import { + ComponentFixture, + fakeAsync, + TestBed, + tick, +} from '@angular/core/testing'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { Router } from '@angular/router'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { + TranslateLoader, + TranslateModule, + TranslateService, +} from '@ngx-translate/core'; +import { + Observable, + of, + Subscription, +} from 'rxjs'; + +import { AuthService } from '../../core/auth/auth.service'; +import { RemoteData } from '../../core/data/remote-data'; +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 { NativeWindowService } from '../../core/services/window.service'; +import { Registration } from '../../core/shared/registration.model'; +import { ExternalLoginService } from '../../external-log-in/services/external-login.service'; +import { AuthServiceMock } from '../../shared/mocks/auth.service.mock'; +import { NativeWindowMockFactory } from '../../shared/mocks/mock-native-window-ref'; +import { RouterMock } from '../../shared/mocks/router.mock'; +import { TranslateLoaderMock } from '../../shared/mocks/translate-loader.mock'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; +import { EPersonMock } from '../../shared/testing/eperson.mock'; +import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub'; +import { CompareValuesPipe } from '../helpers/compare-values.pipe'; +import { ReviewAccountInfoComponent } from './review-account-info.component'; + +describe('ReviewAccountInfoComponent', () => { + let component: ReviewAccountInfoComponent; + let componentAsAny: any; + let fixture: ComponentFixture; + 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> { + return createSuccessfulRemoteDataObject$(mockEPerson); + }, + mergeEPersonDataWithToken( + token: string, + metadata?: string, + ): Observable> { + 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({ + 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, + BrowserAnimationsModule, + ReviewAccountInfoComponent, + CompareValuesPipe, + 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 = 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', [ + 'unsubscribe', + ]); + const subscription2 = jasmine.createSpyObj('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(); + }); +}); diff --git a/src/app/external-login-review-account-info-page/review-account-info/review-account-info.component.ts b/src/app/external-login-review-account-info-page/review-account-info/review-account-info.component.ts new file mode 100644 index 00000000000..f7ce0178d10 --- /dev/null +++ b/src/app/external-login-review-account-info-page/review-account-info/review-account-info.component.ts @@ -0,0 +1,323 @@ +import { + NgFor, + NgIf, + TitleCasePipe, +} from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + Inject, + Input, + OnDestroy, + OnInit, +} from '@angular/core'; +import { Router } from '@angular/router'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { + TranslateModule, + TranslateService, +} from '@ngx-translate/core'; +import { UiSwitchModule } from 'ngx-ui-switch'; +import { + combineLatest, + filter, + from, + map, + Observable, + Subscription, + switchMap, + take, + tap, +} from 'rxjs'; + +import { AuthService } from '../../core/auth/auth.service'; +import { AuthRegistrationType } from '../../core/auth/models/auth.registration-type'; +import { RemoteData } from '../../core/data/remote-data'; +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 { + NativeWindowRef, + NativeWindowService, +} from '../../core/services/window.service'; +import { Registration } from '../../core/shared/registration.model'; +import { ExternalLoginService } from '../../external-log-in/services/external-login.service'; +import { AlertComponent } from '../../shared/alert/alert.component'; +import { AlertType } from '../../shared/alert/alert-type'; +import { ConfirmationModalComponent } from '../../shared/confirmation-modal/confirmation-modal.component'; +import { hasValue } from '../../shared/empty.util'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { CompareValuesPipe } from '../helpers/compare-values.pipe'; + +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'], + imports: [ + CompareValuesPipe, + NgFor, + NgIf, + TitleCasePipe, + TranslateModule, + AlertComponent, + UiSwitchModule, + ], + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: true, +}) +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>; + 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 object. + */ + handleAuthenticatedUser(override$: Observable>) { + this.subs.push( + override$.subscribe((response: RemoteData) => { + 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>, 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 { + 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()); + } +} diff --git a/src/app/external-login-review-account-info-page/themed-external-login-review-account-info-page.component.ts b/src/app/external-login-review-account-info-page/themed-external-login-review-account-info-page.component.ts new file mode 100644 index 00000000000..53c1f223a75 --- /dev/null +++ b/src/app/external-login-review-account-info-page/themed-external-login-review-account-info-page.component.ts @@ -0,0 +1,28 @@ +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-external-login-page', + styleUrls: [], + templateUrl: './../shared/theme-support/themed.component.html', + standalone: true, + imports: [ExternalLoginReviewAccountInfoPageComponent], +}) +export class ThemedExternalLoginReviewAccountInfoPageComponent extends ThemedComponent { + protected getComponentName(): string { + return 'ExternalLoginReviewAccountInfoPageComponent'; + } + + protected importThemedComponent(themeName: string): Promise { + return import(`../../themes/${themeName}/app/external-login-review-account-info/external-login-review-account-info-page.component`); + } + + protected importUnthemedComponent(): Promise { + return import(`./external-login-review-account-info-page.component`); + } +} diff --git a/src/app/register-email-form/registration.resolver.spec.ts b/src/app/register-email-form/registration.resolver.spec.ts index f3b6b0e4042..0d70c7d50f7 100644 --- a/src/app/register-email-form/registration.resolver.spec.ts +++ b/src/app/register-email-form/registration.resolver.spec.ts @@ -14,7 +14,7 @@ describe('registrationResolver', () => { beforeEach(() => { epersonRegistrationService = jasmine.createSpyObj('epersonRegistrationService', { - searchByToken: createSuccessfulRemoteDataObject$(registration), + searchByTokenAndUpdateData: createSuccessfulRemoteDataObject$(registration), }); resolver = registrationResolver; }); diff --git a/src/app/register-email-form/registration.resolver.ts b/src/app/register-email-form/registration.resolver.ts index b87f70bf4e1..787fd84e612 100644 --- a/src/app/register-email-form/registration.resolver.ts +++ b/src/app/register-email-form/registration.resolver.ts @@ -17,7 +17,7 @@ export const registrationResolver: ResolveFn> = ( epersonRegistrationService: EpersonRegistrationService = inject(EpersonRegistrationService), ): Observable> => { const token = route.params.token; - return epersonRegistrationService.searchByToken(token).pipe( + return epersonRegistrationService.searchByTokenAndUpdateData(token).pipe( getFirstCompletedRemoteData(), ); }; diff --git a/src/app/register-page/registration.guard.spec.ts b/src/app/register-page/registration.guard.spec.ts index 31bc751993f..aeb53bcf2b5 100644 --- a/src/app/register-page/registration.guard.spec.ts +++ b/src/app/register-page/registration.guard.spec.ts @@ -53,7 +53,7 @@ describe('registrationGuard', () => { }); epersonRegistrationService = jasmine.createSpyObj('epersonRegistrationService', { - searchByToken: observableOf(registrationRD), + searchByTokenAndUpdateData: observableOf(registrationRD), }); router = jasmine.createSpyObj('router', { navigateByUrl: Promise.resolve(), @@ -71,7 +71,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 +98,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', () => { diff --git a/src/app/register-page/registration.guard.ts b/src/app/register-page/registration.guard.ts index 49614208e21..a37e052717b 100644 --- a/src/app/register-page/registration.guard.ts +++ b/src/app/register-page/registration.guard.ts @@ -26,7 +26,7 @@ export const registrationGuard: CanActivateFn = ( router: Router = inject(Router), ): Observable => { const token = route.params.token; - return epersonRegistrationService.searchByToken(token).pipe( + return epersonRegistrationService.searchByTokenAndUpdateData(token).pipe( getFirstCompletedRemoteData(), redirectOn4xx(router, authService), map((rd) => { diff --git a/src/app/shared/external-log-in-complete/email-confirmation/confirm-email/confirm-email.component.html b/src/app/shared/external-log-in-complete/email-confirmation/confirm-email/confirm-email.component.html new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/app/shared/external-log-in-complete/email-confirmation/confirm-email/confirm-email.component.scss b/src/app/shared/external-log-in-complete/email-confirmation/confirm-email/confirm-email.component.scss new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/app/shared/external-log-in-complete/email-confirmation/confirmation-sent/confirmation-sent.component.scss b/src/app/shared/external-log-in-complete/email-confirmation/confirmation-sent/confirmation-sent.component.scss new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/app/shared/external-log-in-complete/email-confirmation/provide-email/provide-email.component.scss b/src/app/shared/external-log-in-complete/email-confirmation/provide-email/provide-email.component.scss new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/app/shared/external-log-in-complete/external-log-in/external-log-in.component.scss b/src/app/shared/external-log-in-complete/external-log-in/external-log-in.component.scss new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/app/shared/external-log-in-complete/registration-types/orcid-confirmation/orcid-confirmation.component.scss b/src/app/shared/external-log-in-complete/registration-types/orcid-confirmation/orcid-confirmation.component.scss new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/app/shared/log-in/container/log-in-container.component.html b/src/app/shared/log-in/container/log-in-container.component.html index bef6f43b667..3b6ea5d054c 100644 --- a/src/app/shared/log-in/container/log-in-container.component.html +++ b/src/app/shared/log-in/container/log-in-container.component.html @@ -2,4 +2,3 @@ *ngComponentOutlet="getAuthMethodContent(); injector: objectInjector;"> - diff --git a/src/app/shared/log-in/log-in.component.ts b/src/app/shared/log-in/log-in.component.ts index 3c7d829a48c..2f7ee1abc52 100644 --- a/src/app/shared/log-in/log-in.component.ts +++ b/src/app/shared/log-in/log-in.component.ts @@ -13,13 +13,14 @@ import { select, Store, } from '@ngrx/store'; -import { - map, - Observable, -} from 'rxjs'; +import { TranslateModule } from '@ngx-translate/core'; +import uniqBy from 'lodash/uniqBy'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; import { AuthService } from '../../core/auth/auth.service'; import { AuthMethod } from '../../core/auth/models/auth.method'; +import { AuthMethodType } from '../../core/auth/models/auth.method-type'; import { getAuthenticationError, getAuthenticationMethods, @@ -38,7 +39,7 @@ import { rendersAuthMethodType } from './methods/log-in.methods-decorator'; styleUrls: ['./log-in.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, - imports: [NgIf, ThemedLoadingComponent, NgFor, LogInContainerComponent, AsyncPipe], + imports: [NgIf, ThemedLoadingComponent, NgFor, LogInContainerComponent, AsyncPipe, TranslateModule], }) export class LogInComponent implements OnInit { @@ -48,6 +49,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 +85,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 diff --git a/src/app/shared/log-in/methods/log-in-external-provider/log-in-external-provider.component.spec.ts b/src/app/shared/log-in/methods/log-in-external-provider/log-in-external-provider.component.spec.ts index 104bbd21cea..293d818e3f4 100644 --- a/src/app/shared/log-in/methods/log-in-external-provider/log-in-external-provider.component.spec.ts +++ b/src/app/shared/log-in/methods/log-in-external-provider/log-in-external-provider.component.spec.ts @@ -108,8 +108,7 @@ describe('LogInExternalProviderComponent', () => { component.redirectToExternalProvider(); - expect(setHrefSpy).toHaveBeenCalledWith(currentUrl); - + expect(hardRedirectService.redirect).toHaveBeenCalled(); }); it('should not set a new redirectUrl', () => { diff --git a/src/app/shared/log-in/methods/log-in-external-provider/log-in-external-provider.component.ts b/src/app/shared/log-in/methods/log-in-external-provider/log-in-external-provider.component.ts index 13741f412e3..ab82a664a1b 100644 --- a/src/app/shared/log-in/methods/log-in-external-provider/log-in-external-provider.component.ts +++ b/src/app/shared/log-in/methods/log-in-external-provider/log-in-external-provider.component.ts @@ -23,11 +23,7 @@ import { NativeWindowRef, NativeWindowService, } from '../../../../core/services/window.service'; -import { URLCombiner } from '../../../../core/url-combiner/url-combiner'; -import { - isEmpty, - isNotNull, -} from '../../../empty.util'; +import { isEmpty } from '../../../empty.util'; @Component({ selector: 'ds-log-in-external-provider', @@ -104,24 +100,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() { diff --git a/src/app/shared/log-in/themed-log-in.component.ts b/src/app/shared/log-in/themed-log-in.component.ts index 95b2bdd4e08..ef9e0c99792 100644 --- a/src/app/shared/log-in/themed-log-in.component.ts +++ b/src/app/shared/log-in/themed-log-in.component.ts @@ -3,6 +3,7 @@ import { Input, } from '@angular/core'; +import { AuthMethodType } from '../../core/auth/models/auth.method-type'; import { ThemedComponent } from '../theme-support/themed.component'; import { LogInComponent } from './log-in.component'; @@ -20,8 +21,12 @@ export class ThemedLogInComponent extends ThemedComponent { @Input() isStandalonePage: boolean; + @Input() excludedAuthMethod: AuthMethodType; + + @Input() showRegisterLink = true; + protected inAndOutputNames: (keyof LogInComponent & keyof this)[] = [ - 'isStandalonePage', + 'isStandalonePage', 'excludedAuthMethod', 'showRegisterLink', ]; protected getComponentName(): string { diff --git a/src/app/shared/mocks/auth.service.mock.ts b/src/app/shared/mocks/auth.service.mock.ts index c5737ca1707..249d51de3b4 100644 --- a/src/app/shared/mocks/auth.service.mock.ts +++ b/src/app/shared/mocks/auth.service.mock.ts @@ -29,4 +29,16 @@ export class AuthServiceMock { public isUserIdle(): Observable { return observableOf(false); } + + public getImpersonateID(): string { + return null; + } + + public getRedirectUrl(): Observable { + return; + } + + public getExternalServerRedirectUrl(): string { + return; + } } diff --git a/src/app/shared/testing/auth-service.stub.ts b/src/app/shared/testing/auth-service.stub.ts index 5a40e05d1db..339127b7100 100644 --- a/src/app/shared/testing/auth-service.stub.ts +++ b/src/app/shared/testing/auth-service.stub.ts @@ -3,6 +3,7 @@ import { of as observableOf, } from 'rxjs'; +import { RetrieveAuthMethodsAction } from '../../core/auth/auth.actions'; import { AuthMethod } from '../../core/auth/models/auth.method'; import { AuthMethodType } from '../../core/auth/models/auth.method-type'; import { AuthStatus } from '../../core/auth/models/auth-status.model'; @@ -128,6 +129,7 @@ export class AuthServiceStub { checkAuthenticationCookie() { return; } + setExternalAuthStatus(externalCookie: boolean) { this._isExternalAuth = externalCookie; } @@ -179,4 +181,16 @@ export class AuthServiceStub { clearRedirectUrl() { return; } + + public replaceToken(token: AuthTokenInfo) { + return token; + } + + getRetrieveAuthMethodsAction(authStatus: AuthStatus): RetrieveAuthMethodsAction { + return; + } + + public getExternalServerRedirectUrl(redirectRoute: string, location: string) { + return; + } } diff --git a/src/assets/i18n/en.json5 b/src/assets/i18n/en.json5 index bf866950bce..47c97b8782b 100644 --- a/src/assets/i18n/en.json5 +++ b/src/assets/i18n/en.json5 @@ -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.
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.
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.", } diff --git a/src/assets/i18n/it.json5 b/src/assets/i18n/it.json5 index 4a5d3a4881a..64694ace22e 100644 --- a/src/assets/i18n/it.json5 +++ b/src/assets/i18n/it.json5 @@ -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", @@ -4211,8 +4227,6 @@ // "login.breadcrumbs": "Login", "login.breadcrumbs": "Accesso", - - // "logout.form.header": "Log out from DSpace", "logout.form.header": "Disconnettersi da DSpace", @@ -7856,5 +7870,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.
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.
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.
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.
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.", } diff --git a/src/themes/custom/app/shared/log-in/log-in.component.ts b/src/themes/custom/app/shared/log-in/log-in.component.ts index 1af55058355..63388d01a34 100644 --- a/src/themes/custom/app/shared/log-in/log-in.component.ts +++ b/src/themes/custom/app/shared/log-in/log-in.component.ts @@ -4,6 +4,7 @@ import { NgIf, } from '@angular/common'; import { Component } from '@angular/core'; +import { TranslateModule } from '@ngx-translate/core'; import { ThemedLoadingComponent } from 'src/app/shared/loading/themed-loading.component'; import { LogInContainerComponent } from 'src/app/shared/log-in/container/log-in-container.component'; @@ -16,7 +17,7 @@ import { LogInComponent as BaseComponent } from '../../../../../app/shared/log-i // styleUrls: ['./log-in.component.scss'], styleUrls: ['../../../../../app/shared/log-in/log-in.component.scss'], standalone: true, - imports: [NgIf, ThemedLoadingComponent, NgFor, LogInContainerComponent, AsyncPipe], + imports: [NgIf, ThemedLoadingComponent, NgFor, LogInContainerComponent, AsyncPipe, TranslateModule], }) export class LogInComponent extends BaseComponent { }