From 214a77a65ce0442a4027a074b20b3049c27fe68e Mon Sep 17 00:00:00 2001 From: Alisa Ismailati Date: Mon, 25 Sep 2023 18:10:26 +0200 Subject: [PATCH 01/88] [CST-14902][CST-15073][CST-15074] Adds ORCID login flow with private email --- src/app/app-routes.ts | 32 +- src/app/core/auth/auth-request.service.ts | 56 ++-- src/app/core/auth/auth.service.ts | 109 ++++--- src/app/core/auth/models/auth.method-type.ts | 2 +- .../auth/models/auth.registration-type.ts | 4 + .../core/auth/models/machine-token.model.ts | 36 +++ .../models/machine-token.resource-type.ts | 9 + .../data/eperson-registration.service.spec.ts | 4 +- .../core/data/eperson-registration.service.ts | 106 +++++-- .../core/eperson/eperson-data.service.spec.ts | 58 ++-- src/app/core/eperson/eperson-data.service.ts | 78 +++-- src/app/core/shared/registration.model.ts | 32 ++ .../external-log-in.methods-decorator.ts | 29 ++ .../external-login-method-entry.component.ts | 22 ++ .../confirm-email.component.html | 37 +++ .../confirm-email.component.scss | 0 .../confirm-email.component.spec.ts | 161 ++++++++++ .../confirm-email/confirm-email.component.ts | 160 ++++++++++ .../confirmation-sent.component.html | 5 + .../confirmation-sent.component.scss | 0 .../confirmation-sent.component.spec.ts | 63 ++++ .../confirmation-sent.component.ts | 9 + .../provide-email.component.html | 38 +++ .../provide-email.component.scss | 0 .../provide-email.component.spec.ts | 62 ++++ .../provide-email/provide-email.component.ts | 56 ++++ .../external-log-in.component.html | 49 +++ .../external-log-in.component.scss | 0 .../external-log-in.component.spec.ts | 106 +++++++ .../external-log-in.component.ts | 149 ++++++++++ .../external-log-in/external-login.module.ts | 35 +++ .../guards/registration-token.guard.spec.ts | 79 +++++ .../guards/registration-token.guard.ts | 49 +++ .../models/registration-data.mock.model.ts | 45 +++ .../orcid-confirmation.component.html | 35 +++ .../orcid-confirmation.component.scss | 0 .../orcid-confirmation.component.spec.ts | 69 +++++ .../orcid-confirmation.component.ts | 60 ++++ .../registration-data.resolver.spec.ts | 46 +++ .../resolvers/registration-data.resolver.ts | 38 +++ .../services/external-login.service.spec.ts | 78 +++++ .../services/external-login.service.ts | 65 ++++ ...al-login-email-confirmation-page-routes.ts | 10 + ...-email-confirmation-page-routing.module.ts | 17 ++ ...gin-email-confirmation-page.component.html | 3 + ...gin-email-confirmation-page.component.scss | 0 ...-email-confirmation-page.component.spec.ts | 45 +++ ...login-email-confirmation-page.component.ts | 8 + ...al-login-email-confirmation-page.module.ts | 21 ++ .../external-login-page-routing.module.ts | 22 ++ .../external-login-page.component.html | 11 + .../external-login-page.component.scss | 0 .../external-login-page.component.spec.ts | 79 +++++ .../external-login-page.component.ts | 48 +++ .../external-login-page.module.ts | 26 ++ .../external-login-routes.ts | 14 + .../themed-external-login-page.component.ts | 25 ++ ...l-login-review-account-info-page-routes.ts | 14 + ...review-account-info-page-routing.module.ts | 21 ++ ...in-review-account-info-page.component.html | 13 + ...in-review-account-info-page.component.scss | 0 ...review-account-info-page.component.spec.ts | 60 ++++ ...ogin-review-account-info-page.component.ts | 47 +++ ...l-login-review-account-info-page.module.ts | 30 ++ .../helpers/compare-values.pipe.ts | 22 ++ .../helpers/review-account.guard.spec.ts | 78 +++++ .../helpers/review-account.guard.ts | 65 ++++ .../review-account-info.component.html | 59 ++++ .../review-account-info.component.scss | 13 + .../review-account-info.component.spec.ts | 229 ++++++++++++++ .../review-account-info.component.ts | 281 ++++++++++++++++++ ...ogin-review-account-info-page.component.ts | 25 ++ .../registration.resolver.spec.ts | 2 +- .../registration.resolver.ts | 8 +- .../register-page/registration.guard.spec.ts | 21 +- .../confirm-email.component.html | 0 .../confirm-email.component.scss | 0 .../confirmation-sent.component.scss | 0 .../provide-email.component.scss | 0 .../external-log-in.component.scss | 0 .../orcid-confirmation.component.scss | 0 .../container/log-in-container.component.html | 1 - src/app/shared/log-in/log-in.component.ts | 39 +-- ...log-in-external-provider.component.spec.ts | 14 +- .../log-in-external-provider.component.ts | 52 +--- src/app/shared/mocks/auth.service.mock.ts | 17 +- src/app/shared/testing/auth-service.stub.ts | 19 +- src/assets/i18n/en.json5 | 68 +++++ src/assets/i18n/it.json5 | 130 ++++++++ 89 files changed, 3452 insertions(+), 276 deletions(-) create mode 100644 src/app/core/auth/models/auth.registration-type.ts create mode 100644 src/app/core/auth/models/machine-token.model.ts create mode 100644 src/app/core/auth/models/machine-token.resource-type.ts create mode 100644 src/app/external-log-in/decorators/external-log-in.methods-decorator.ts create mode 100644 src/app/external-log-in/decorators/external-login-method-entry.component.ts create mode 100644 src/app/external-log-in/email-confirmation/confirm-email/confirm-email.component.html create mode 100644 src/app/external-log-in/email-confirmation/confirm-email/confirm-email.component.scss create mode 100644 src/app/external-log-in/email-confirmation/confirm-email/confirm-email.component.spec.ts create mode 100644 src/app/external-log-in/email-confirmation/confirm-email/confirm-email.component.ts create mode 100644 src/app/external-log-in/email-confirmation/confirmation-sent/confirmation-sent.component.html create mode 100644 src/app/external-log-in/email-confirmation/confirmation-sent/confirmation-sent.component.scss create mode 100644 src/app/external-log-in/email-confirmation/confirmation-sent/confirmation-sent.component.spec.ts create mode 100644 src/app/external-log-in/email-confirmation/confirmation-sent/confirmation-sent.component.ts create mode 100644 src/app/external-log-in/email-confirmation/provide-email/provide-email.component.html create mode 100644 src/app/external-log-in/email-confirmation/provide-email/provide-email.component.scss create mode 100644 src/app/external-log-in/email-confirmation/provide-email/provide-email.component.spec.ts create mode 100644 src/app/external-log-in/email-confirmation/provide-email/provide-email.component.ts create mode 100644 src/app/external-log-in/external-log-in/external-log-in.component.html create mode 100644 src/app/external-log-in/external-log-in/external-log-in.component.scss create mode 100644 src/app/external-log-in/external-log-in/external-log-in.component.spec.ts create mode 100644 src/app/external-log-in/external-log-in/external-log-in.component.ts create mode 100644 src/app/external-log-in/external-login.module.ts create mode 100644 src/app/external-log-in/guards/registration-token.guard.spec.ts create mode 100644 src/app/external-log-in/guards/registration-token.guard.ts create mode 100644 src/app/external-log-in/models/registration-data.mock.model.ts create mode 100644 src/app/external-log-in/registration-types/orcid-confirmation/orcid-confirmation.component.html create mode 100644 src/app/external-log-in/registration-types/orcid-confirmation/orcid-confirmation.component.scss create mode 100644 src/app/external-log-in/registration-types/orcid-confirmation/orcid-confirmation.component.spec.ts create mode 100644 src/app/external-log-in/registration-types/orcid-confirmation/orcid-confirmation.component.ts create mode 100644 src/app/external-log-in/resolvers/registration-data.resolver.spec.ts create mode 100644 src/app/external-log-in/resolvers/registration-data.resolver.ts create mode 100644 src/app/external-log-in/services/external-login.service.spec.ts create mode 100644 src/app/external-log-in/services/external-login.service.ts create mode 100644 src/app/external-login-email-confirmation-page/external-login-email-confirmation-page-routes.ts create mode 100644 src/app/external-login-email-confirmation-page/external-login-email-confirmation-page-routing.module.ts create mode 100644 src/app/external-login-email-confirmation-page/external-login-email-confirmation-page.component.html create mode 100644 src/app/external-login-email-confirmation-page/external-login-email-confirmation-page.component.scss create mode 100644 src/app/external-login-email-confirmation-page/external-login-email-confirmation-page.component.spec.ts create mode 100644 src/app/external-login-email-confirmation-page/external-login-email-confirmation-page.component.ts create mode 100644 src/app/external-login-email-confirmation-page/external-login-email-confirmation-page.module.ts create mode 100644 src/app/external-login-page/external-login-page-routing.module.ts create mode 100644 src/app/external-login-page/external-login-page.component.html create mode 100644 src/app/external-login-page/external-login-page.component.scss create mode 100644 src/app/external-login-page/external-login-page.component.spec.ts create mode 100644 src/app/external-login-page/external-login-page.component.ts create mode 100644 src/app/external-login-page/external-login-page.module.ts create mode 100644 src/app/external-login-page/external-login-routes.ts create mode 100644 src/app/external-login-page/themed-external-login-page.component.ts create mode 100644 src/app/external-login-review-account-info-page/external-login-review-account-info-page-routes.ts create mode 100644 src/app/external-login-review-account-info-page/external-login-review-account-info-page-routing.module.ts create mode 100644 src/app/external-login-review-account-info-page/external-login-review-account-info-page.component.html create mode 100644 src/app/external-login-review-account-info-page/external-login-review-account-info-page.component.scss create mode 100644 src/app/external-login-review-account-info-page/external-login-review-account-info-page.component.spec.ts create mode 100644 src/app/external-login-review-account-info-page/external-login-review-account-info-page.component.ts create mode 100644 src/app/external-login-review-account-info-page/external-login-review-account-info-page.module.ts create mode 100644 src/app/external-login-review-account-info-page/helpers/compare-values.pipe.ts create mode 100644 src/app/external-login-review-account-info-page/helpers/review-account.guard.spec.ts create mode 100644 src/app/external-login-review-account-info-page/helpers/review-account.guard.ts create mode 100644 src/app/external-login-review-account-info-page/review-account-info/review-account-info.component.html create mode 100644 src/app/external-login-review-account-info-page/review-account-info/review-account-info.component.scss create mode 100644 src/app/external-login-review-account-info-page/review-account-info/review-account-info.component.spec.ts create mode 100644 src/app/external-login-review-account-info-page/review-account-info/review-account-info.component.ts create mode 100644 src/app/external-login-review-account-info-page/themed-external-login-review-account-info-page.component.ts create mode 100644 src/app/shared/external-log-in-complete/email-confirmation/confirm-email/confirm-email.component.html create mode 100644 src/app/shared/external-log-in-complete/email-confirmation/confirm-email/confirm-email.component.scss create mode 100644 src/app/shared/external-log-in-complete/email-confirmation/confirmation-sent/confirmation-sent.component.scss create mode 100644 src/app/shared/external-log-in-complete/email-confirmation/provide-email/provide-email.component.scss create mode 100644 src/app/shared/external-log-in-complete/external-log-in/external-log-in.component.scss create mode 100644 src/app/shared/external-log-in-complete/registration-types/orcid-confirmation/orcid-confirmation.component.scss diff --git a/src/app/app-routes.ts b/src/app/app-routes.ts index 29a78364b5..c298068281 100644 --- a/src/app/app-routes.ts +++ b/src/app/app-routes.ts @@ -1,8 +1,4 @@ -import { - InMemoryScrollingOptions, - Route, - RouterConfigOptions, -} from '@angular/router'; +import { InMemoryScrollingOptions, Route, RouterConfigOptions, } from '@angular/router'; import { NOTIFICATIONS_MODULE_PATH } from './admin/admin-routing-paths'; import { @@ -25,8 +21,12 @@ import { COLLECTION_MODULE_PATH } from './collection-page/collection-page-routin import { COMMUNITY_MODULE_PATH } from './community-page/community-page-routing-paths'; import { authBlockingGuard } from './core/auth/auth-blocking.guard'; import { authenticatedGuard } from './core/auth/authenticated.guard'; -import { groupAdministratorGuard } from './core/data/feature-authorization/feature-authorization-guard/group-administrator.guard'; -import { siteAdministratorGuard } from './core/data/feature-authorization/feature-authorization-guard/site-administrator.guard'; +import { + groupAdministratorGuard +} from './core/data/feature-authorization/feature-authorization-guard/group-administrator.guard'; +import { + siteAdministratorGuard +} from './core/data/feature-authorization/feature-authorization-guard/site-administrator.guard'; import { siteRegisterGuard } from './core/data/feature-authorization/feature-authorization-guard/site-register.guard'; import { endUserAgreementCurrentUserGuard } from './core/end-user-agreement/end-user-agreement-current-user.guard'; import { reloadGuard } from './core/reload/reload.guard'; @@ -37,7 +37,9 @@ import { ITEM_MODULE_PATH } from './item-page/item-page-routing-paths'; import { menuResolver } from './menuResolver'; import { provideSuggestionNotificationsState } from './notifications/provide-suggestion-notifications-state'; import { ThemedPageErrorComponent } from './page-error/themed-page-error.component'; -import { ThemedPageInternalServerErrorComponent } from './page-internal-server-error/themed-page-internal-server-error.component'; +import { + ThemedPageInternalServerErrorComponent +} from './page-internal-server-error/themed-page-internal-server-error.component'; import { ThemedPageNotFoundComponent } from './pagenotfound/themed-pagenotfound.component'; import { PROCESS_MODULE_PATH } from './process-page/process-page-routing.paths'; import { provideSubmissionState } from './submission/provide-submission-state'; @@ -257,6 +259,20 @@ export const APP_ROUTES: Route[] = [ .then((m) => m.ROUTES), canActivate: [authenticatedGuard], }, + { + path: 'external-login/:token', + loadChildren: () => import('./external-login-page/external-login-routes').then((m) => m.ROUTES) + }, + { + path: 'review-account/:token', + loadChildren: () => import('./external-login-review-account-info-page/external-login-review-account-info-page-routes') + .then((m) => m.ROUTES) + }, + { + path: 'email-confirmation', + loadChildren: () => import('./external-login-email-confirmation-page/external-login-email-confirmation-page-routes') + .then((m) => m.ROUTES) + }, { path: '**', pathMatch: 'full', component: ThemedPageNotFoundComponent }, ], }, diff --git a/src/app/core/auth/auth-request.service.ts b/src/app/core/auth/auth-request.service.ts index 5d11b9f4cb..5c4843a937 100644 --- a/src/app/core/auth/auth-request.service.ts +++ b/src/app/core/auth/auth-request.service.ts @@ -1,29 +1,22 @@ import { Observable } from 'rxjs'; -import { - distinctUntilChanged, - filter, - map, - switchMap, - take, - tap, -} from 'rxjs/operators'; +import { distinctUntilChanged, filter, map, switchMap, take, tap, } from 'rxjs/operators'; -import { isNotEmpty } from '../../shared/empty.util'; +import { isNotEmpty, isNotEmptyOperator } from '../../shared/empty.util'; import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { RequestService } from '../data/request.service'; +import { DeleteRequest, GetRequest, PostRequest } from '../data/request.models'; +import { HttpOptions } from '../dspace-rest/dspace-rest.service'; +import { getFirstCompletedRemoteData } from '../shared/operators'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { RemoteData } from '../data/remote-data'; -import { - GetRequest, - PostRequest, -} from '../data/request.models'; -import { RequestService } from '../data/request.service'; import { RestRequest } from '../data/rest-request.model'; -import { HttpOptions } from '../dspace-rest/dspace-rest.service'; -import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { getFirstCompletedRemoteData } from '../shared/operators'; import { URLCombiner } from '../url-combiner/url-combiner'; import { AuthStatus } from './models/auth-status.model'; import { ShortLivedToken } from './models/short-lived-token.model'; +import { MachineToken } from './models/machine-token.model'; +import { NoContent } from '../shared/NoContent.model'; +import { sendRequest } from '../shared/request.operators'; /** * Abstract service to send authentication requests @@ -31,6 +24,7 @@ import { ShortLivedToken } from './models/short-lived-token.model'; export abstract class AuthRequestService { protected linkName = 'authn'; protected shortlivedtokensEndpoint = 'shortlivedtokens'; + protected machinetokenEndpoint = 'machinetokens'; constructor(protected halService: HALEndpointService, protected requestService: RequestService, @@ -139,4 +133,32 @@ export abstract class AuthRequestService { }), ); } + + /** + * Send a post request to create a machine token + */ + public postToMachineTokenEndpoint(): Observable> { + 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 cd773b68cf..27ed870a6a 100644 --- a/src/app/core/auth/auth.service.ts +++ b/src/app/core/auth/auth.service.ts @@ -1,33 +1,14 @@ import { HttpHeaders } from '@angular/common/http'; -import { - Inject, - Injectable, - Optional, -} from '@angular/core'; +import { Inject, Injectable, Optional, } from '@angular/core'; import { Router } from '@angular/router'; -import { - select, - Store, -} from '@ngrx/store'; +import { select, Store, } from '@ngrx/store'; import { TranslateService } from '@ngx-translate/core'; import { CookieAttributes } from 'js-cookie'; -import { - Observable, - of as observableOf, -} from 'rxjs'; -import { - filter, - map, - startWith, - switchMap, - take, -} from 'rxjs/operators'; +import { Observable, of as observableOf, } from 'rxjs'; +import { filter, map, startWith, switchMap, take, } from 'rxjs/operators'; import { environment } from '../../../environments/environment'; -import { - REQUEST, - RESPONSE, -} from '../../../express.tokens'; +import { REQUEST, RESPONSE, } from '../../../express.tokens'; import { AppState } from '../../app.reducer'; import { hasNoValue, @@ -41,10 +22,7 @@ import { import { NotificationsService } from '../../shared/notifications/notifications.service'; import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; import { followLink } from '../../shared/utils/follow-link-config.model'; -import { - buildPaginatedList, - PaginatedList, -} from '../data/paginated-list.model'; +import { buildPaginatedList, PaginatedList, } from '../data/paginated-list.model'; import { RemoteData } from '../data/remote-data'; import { HttpOptions } from '../dspace-rest/dspace-rest.service'; import { EPersonDataService } from '../eperson/eperson-data.service'; @@ -54,13 +32,16 @@ import { CookieService } from '../services/cookie.service'; import { HardRedirectService } from '../services/hard-redirect.service'; import { RouteService } from '../services/route.service'; import { - NativeWindowRef, - NativeWindowService, -} from '../services/window.service'; -import { - getAllSucceededRemoteDataPayload, - getFirstCompletedRemoteData, -} from '../shared/operators'; + getAuthenticatedUserId, + getAuthenticationToken, + getExternalAuthCookieStatus, + getRedirectUrl, + isAuthenticated, + isAuthenticatedLoaded, + isIdle, + isTokenRefreshing +} from './selectors'; +import { getAllSucceededRemoteDataPayload, getFirstCompletedRemoteData, } from '../shared/operators'; import { PageInfo } from '../shared/page-info.model'; import { CheckAuthenticationTokenAction, @@ -74,20 +55,11 @@ import { import { AuthRequestService } from './auth-request.service'; import { AuthMethod } from './models/auth.method'; import { AuthStatus } from './models/auth-status.model'; -import { - AuthTokenInfo, - TOKENITEM, -} from './models/auth-token-info.model'; -import { - getAuthenticatedUserId, - getAuthenticationToken, - getExternalAuthCookieStatus, - getRedirectUrl, - isAuthenticated, - isAuthenticatedLoaded, - isIdle, - isTokenRefreshing, -} from './selectors'; +import { AuthTokenInfo, TOKENITEM, } from './models/auth-token-info.model'; +import { NoContent } from '../shared/NoContent.model'; +import { URLCombiner } from '../url-combiner/url-combiner'; +import { MachineToken } from './models/machine-token.model'; +import { NativeWindowRef, NativeWindowService } from '../services/window.service'; export const LOGIN_ROUTE = '/login'; export const LOGOUT_ROUTE = '/logout'; @@ -579,6 +551,31 @@ export class AuthService { }); } + /** + * Returns the external server redirect URL. + * @param origin - The origin route. + * @param redirectRoute - The redirect route. + * @param location - The location. + * @returns The external server redirect URL. + */ + getExternalServerRedirectUrl(origin: string, redirectRoute: string, location: string): string { + const correctRedirectUrl = new URLCombiner(origin, redirectRoute).toString(); + + let externalServerUrl = location; + const myRegexp = /\?redirectUrl=(.*)/g; + const match = myRegexp.exec(location); + const redirectUrlFromServer = (match && match[1]) ? match[1] : null; + + // Check whether the current page is different from the redirect url received from rest + if (isNotNull(redirectUrlFromServer) && redirectUrlFromServer !== correctRedirectUrl) { + // change the redirect url with the current page url + const newRedirectUrl = `?redirectUrl=${correctRedirectUrl}`; + externalServerUrl = location.replace(/\?redirectUrl=(.*)/g, newRedirectUrl); + } + + return externalServerUrl; + } + /** * Clear redirect url */ @@ -664,4 +661,18 @@ export class AuthService { } } + /** + * Create a new machine token for the current user + */ + public createMachineToken(): Observable> { + 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 594d6d8b39..0a68ae9cad 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 0000000000..b8aaa1fe40 --- /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 0000000000..0b22efb8bf --- /dev/null +++ b/src/app/core/auth/models/machine-token.model.ts @@ -0,0 +1,36 @@ +import { autoserialize, autoserializeAs, deserialize } from 'cerialize'; + +import { typedObject } from '../../cache/builders/build-decorators'; +import { CacheableObject } from '../../cache/cacheable-object.model'; +import { excludeFromEquals } from '../../utilities/equals.decorators'; +import { ResourceType } from '../../shared/resource-type'; +import { HALLink } from '../../shared/hal-link.model'; +import { MACHINE_TOKEN } from './machine-token.resource-type'; + +/** + * A machine token that can be used to authenticate a rest request + */ +@typedObject +export class MachineToken implements CacheableObject { + static type = MACHINE_TOKEN; + /** + * The type for this MachineToken + */ + @excludeFromEquals + @autoserialize + type: ResourceType; + + /** + * The value for this MachineToken + */ + @autoserializeAs('token') + value: string; + + /** + * The {@link HALLink}s for this MachineToken + */ + @deserialize + _links: { + self: HALLink; + }; +} 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 0000000000..c3d3dabeb9 --- /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 a60cef121a..acf9ab284a 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 90a3fab83a..524a9bbda3 100644 --- a/src/app/core/data/eperson-registration.service.ts +++ b/src/app/core/data/eperson-registration.service.ts @@ -1,33 +1,22 @@ -import { - HttpHeaders, - HttpParams, -} from '@angular/common/http'; +import { HttpHeaders, HttpParams, } from '@angular/common/http'; import { Injectable } from '@angular/core'; +import { RequestService } from './request.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { GetRequest, PatchRequest, PostRequest } from './request.models'; import { Observable } from 'rxjs'; -import { - filter, - find, - map, -} from 'rxjs/operators'; +import { filter, find, map, } from 'rxjs/operators'; -import { - hasValue, - isNotEmpty, -} from '../../shared/empty.util'; +import { hasValue, isNotEmpty, } from '../../shared/empty.util'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { HttpOptions } from '../dspace-rest/dspace-rest.service'; import { GenericConstructor } from '../shared/generic-constructor'; -import { HALEndpointService } from '../shared/hal-endpoint.service'; import { getFirstCompletedRemoteData } from '../shared/operators'; import { Registration } from '../shared/registration.model'; import { ResponseParsingService } from './parsing.service'; import { RegistrationResponseParsingService } from './registration-response-parsing.service'; import { RemoteData } from './remote-data'; -import { - GetRequest, - PostRequest, -} from './request.models'; -import { RequestService } from './request.service'; +import { Operation } from 'fast-json-patch'; +import { NoContent } from '../shared/NoContent.model'; @Injectable({ providedIn: 'root', @@ -45,7 +34,6 @@ export class EpersonRegistrationService { protected rdbService: RemoteDataBuildService, protected halService: HALEndpointService, ) { - } /** @@ -103,10 +91,11 @@ export class EpersonRegistrationService { } /** - * Search a registration based on the provided token - * @param token + * Searches for a registration based on the provided token. + * @param token The token to search for. + * @returns An observable of remote data containing the registration. */ - searchByToken(token: string): Observable> { + searchByTokenAndUpdateData(token: string): Observable> { const requestId = this.requestService.generateRequestId(); const href$ = this.getTokenSearchEndpoint(token).pipe( @@ -126,7 +115,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 +127,69 @@ export class EpersonRegistrationService { ); } + /** + * Searches for a registration by token and handles any errors that may occur. + * @param token The token to search for. + * @returns An observable of remote data containing the registration. + */ + searchByTokenAndHandleError(token: string): Observable> { + 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 749afb5daa..84ab2e2d67 100644 --- a/src/app/core/eperson/eperson-data.service.spec.ts +++ b/src/app/core/eperson/eperson-data.service.spec.ts @@ -1,19 +1,8 @@ import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; -import { - fakeAsync, - TestBed, - tick, - waitForAsync, -} from '@angular/core/testing'; +import { fakeAsync, TestBed, tick, waitForAsync, } from '@angular/core/testing'; import { Store } from '@ngrx/store'; -import { - MockStore, - provideMockStore, -} from '@ngrx/store/testing'; -import { - compare, - Operation, -} from 'fast-json-patch'; +import { MockStore, provideMockStore, } from '@ngrx/store/testing'; +import { compare, Operation, } from 'fast-json-patch'; import { cold } from 'jasmine-marbles'; import { of as observableOf } from 'rxjs'; @@ -24,21 +13,12 @@ import { import { getMockRemoteDataBuildServiceHrefMap } from '../../shared/mocks/remote-data-build.service.mock'; import { getMockRequestService } from '../../shared/mocks/request.service.mock'; import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { - createNoContentRemoteDataObject$, - createSuccessfulRemoteDataObject$, -} from '../../shared/remote-data.utils'; -import { - EPersonMock, - EPersonMock2, -} from '../../shared/testing/eperson.mock'; +import { createNoContentRemoteDataObject$, createSuccessfulRemoteDataObject$, } from '../../shared/remote-data.utils'; +import { EPersonMock, EPersonMock2, } from '../../shared/testing/eperson.mock'; import { GroupMock } from '../../shared/testing/group-mock'; import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub'; import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub'; -import { - createPaginatedList, - createRequestEntry$, -} from '../../shared/testing/utils.test'; +import { createPaginatedList, createRequestEntry$, } from '../../shared/testing/utils.test'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { RequestParam } from '../cache/models/request-param.model'; import { ObjectCacheService } from '../cache/object-cache.service'; @@ -46,18 +26,13 @@ import { CoreState } from '../core-state.model'; import { ChangeAnalyzer } from '../data/change-analyzer'; import { DSOChangeAnalyzer } from '../data/dso-change-analyzer.service'; import { FindListOptions } from '../data/find-list-options.model'; -import { - PatchRequest, - PostRequest, -} from '../data/request.models'; +import { PatchRequest, PostRequest, } from '../data/request.models'; import { RequestService } from '../data/request.service'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { Item } from '../shared/item.model'; -import { - editEPersonSelector, - EPersonDataService, -} from './eperson-data.service'; +import { editEPersonSelector, EPersonDataService, } from './eperson-data.service'; import { EPerson } from './models/eperson.model'; +import { RemoteData } from '../data/remote-data'; describe('EPersonDataService', () => { let service: EPersonDataService; @@ -351,6 +326,21 @@ describe('EPersonDataService', () => { }); }); + describe('mergeEPersonDataWithToken', () => { + const uuid = '1234-5678-9012-3456'; + const token = 'abcd-efgh-ijkl-mnop'; + const metadataKey = 'eperson.firstname'; + beforeEach(() => { + spyOn(service, 'mergeEPersonDataWithToken').and.returnValue(createSuccessfulRemoteDataObject$(EPersonMock)); + }); + + it('should merge EPerson data with token', () => { + service.mergeEPersonDataWithToken(uuid, token, metadataKey).subscribe((result: RemoteData) => { + 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 0de6de7407..7ffaf0f3ee 100644 --- a/src/app/core/eperson/eperson-data.service.ts +++ b/src/app/core/eperson/eperson-data.service.ts @@ -1,16 +1,8 @@ import { Injectable } from '@angular/core'; -import { - createSelector, - select, - Store, -} from '@ngrx/store'; +import { createSelector, select, Store, } from '@ngrx/store'; import { Operation } from 'fast-json-patch'; import { Observable } from 'rxjs'; -import { - find, - map, - take, -} from 'rxjs/operators'; +import { find, map, take, } from 'rxjs/operators'; import { getEPersonEditRoute } from '../../access-control/access-control-routing-paths'; import { @@ -19,51 +11,27 @@ import { } from '../../access-control/epeople-registry/epeople-registry.actions'; import { EPeopleRegistryState } from '../../access-control/epeople-registry/epeople-registry.reducers'; import { AppState } from '../../app.reducer'; -import { - hasNoValue, - hasValue, -} from '../../shared/empty.util'; +import { hasNoValue, hasValue, } from '../../shared/empty.util'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { RequestParam } from '../cache/models/request-param.model'; import { ObjectCacheService } from '../cache/object-cache.service'; -import { - CreateData, - CreateDataImpl, -} from '../data/base/create-data'; -import { - DeleteData, - DeleteDataImpl, -} from '../data/base/delete-data'; +import { CreateData, CreateDataImpl, } from '../data/base/create-data'; +import { DeleteData, DeleteDataImpl, } from '../data/base/delete-data'; import { IdentifiableDataService } from '../data/base/identifiable-data.service'; -import { - PatchData, - PatchDataImpl, -} from '../data/base/patch-data'; -import { - SearchData, - SearchDataImpl, -} from '../data/base/search-data'; +import { PatchData, PatchDataImpl, } from '../data/base/patch-data'; +import { SearchData, SearchDataImpl, } from '../data/base/search-data'; import { DSOChangeAnalyzer } from '../data/dso-change-analyzer.service'; import { FindListOptions } from '../data/find-list-options.model'; -import { - buildPaginatedList, - PaginatedList, -} from '../data/paginated-list.model'; +import { buildPaginatedList, PaginatedList, } from '../data/paginated-list.model'; import { RemoteData } from '../data/remote-data'; -import { - PatchRequest, - PostRequest, -} from '../data/request.models'; +import { PatchRequest, PostRequest, } from '../data/request.models'; import { RequestService } from '../data/request.service'; import { RestRequestMethod } from '../data/rest-request-method'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { NoContent } from '../shared/NoContent.model'; -import { - getFirstSucceededRemoteData, - getRemoteDataPayload, -} from '../shared/operators'; +import { getFirstSucceededRemoteData, getRemoteDataPayload, } from '../shared/operators'; import { PageInfo } from '../shared/page-info.model'; import { EPerson } from './models/eperson.model'; @@ -394,6 +362,32 @@ export class EPersonDataService extends IdentifiableDataService 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 e2efa6a02c..8bb02a1251 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 { typedObject } from '../cache/builders/build-decorators'; import { REGISTRATION } from './registration.resource-type'; import { ResourceType } from './resource-type'; import { UnCacheableObject } from './uncacheable-object.model'; +import { MetadataValue } from './metadata.models'; +import { AuthRegistrationType } from '../auth/models/auth.registration-type'; +export class RegistrationDataMetadataMap { + [key: string]: RegistrationDataMetadataValue[]; +} + +export class RegistrationDataMetadataValue extends MetadataValue { + overrides?: string; +} @typedObject export class Registration implements UnCacheableObject { static type = REGISTRATION; + + /** + * The unique identifier of this registration data + */ + id: string; + /** * The object type */ @@ -29,8 +45,24 @@ export class Registration implements UnCacheableObject { * The token linked to the registration */ groupNames: string[]; + /** * The token linked to the registration */ groups: string[]; + + /** + * The registration type (e.g. orcid, shibboleth, etc.) + */ + registrationType?: AuthRegistrationType; + + /** + * The netId of the user (e.g. for ORCID - <:orcid>) + */ + netId?: string; + + /** + * The metadata involved during the registration process + */ + registrationMetadata?: RegistrationDataMetadataMap; } 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 0000000000..ce90aea0a3 --- /dev/null +++ b/src/app/external-log-in/decorators/external-log-in.methods-decorator.ts @@ -0,0 +1,29 @@ +import { AuthRegistrationType } from 'src/app/core/auth/models/auth.registration-type'; + +/** + * Map to store the external login confirmation component for the given auth method type + */ +const authMethodsMap = new Map(); +/** + * Decorator to register the external login confirmation component for the given auth method type + * @param authMethodType the type of the external login method + */ +export function renderExternalLoginConfirmationFor( + authMethodType: AuthRegistrationType +) { + return function decorator(objectElement: any) { + if (!objectElement) { + return; + } + authMethodsMap.set(authMethodType, objectElement); + }; +} +/** + * Get the external login confirmation component for the given auth method type + * @param authMethodType the type of the external login method + */ +export function getExternalLoginConfirmationType( + authMethodType: AuthRegistrationType +) { + return authMethodsMap.get(authMethodType); +} 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 0000000000..47158274b8 --- /dev/null +++ b/src/app/external-log-in/decorators/external-login-method-entry.component.ts @@ -0,0 +1,22 @@ +import { Component, Inject } from '@angular/core'; +import { Registration } from '../../core/shared/registration.model'; + +/** + * This component renders a form to complete the registration process + */ +@Component({ + template: '' +}) +export abstract class ExternalLoginMethodEntryComponent { + + /** + * The registration data object + */ + public registratioData: Registration; + + constructor( + @Inject('registrationDataProvider') protected injectedRegistrationDataObject: Registration, + ) { + this.registratioData = injectedRegistrationDataObject; + } +} 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 0000000000..455aaf75e7 --- /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 0000000000..e69de29bb2 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 0000000000..9e514472f0 --- /dev/null +++ b/src/app/external-log-in/email-confirmation/confirm-email/confirm-email.component.spec.ts @@ -0,0 +1,161 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ConfirmEmailComponent } from './confirm-email.component'; +import { FormBuilder, ReactiveFormsModule } from '@angular/forms'; +import { CommonModule } from '@angular/common'; +import { TranslateLoader, TranslateModule, TranslateService, } from '@ngx-translate/core'; +import { EventEmitter, NO_ERRORS_SCHEMA } from '@angular/core'; +import { ExternalLoginService } from '../../services/external-login.service'; +import { of } from 'rxjs'; +import { AuthService } from '../../../core/auth/auth.service'; +import { AuthMethodType } from '../../../core/auth/models/auth.method-type'; +import { EPersonDataService } from '../../../core/eperson/eperson-data.service'; +import { EPerson } from '../../../core/eperson/models/eperson.model'; +import { HardRedirectService } from '../../../core/services/hard-redirect.service'; +import { Registration } from '../../../core/shared/registration.model'; +import { TranslateLoaderMock } from '../../../shared/mocks/translate-loader.mock'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; +import { NativeWindowService } from '../../../core/services/window.service'; +import { MockWindow, NativeWindowMockFactory } from '../../../shared/mocks/mock-native-window-ref'; +import { By } from '@angular/platform-browser'; + +describe('ConfirmEmailComponent', () => { + let component: ConfirmEmailComponent; + let fixture: ComponentFixture; + 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({ + declarations: [ConfirmEmailComponent], + providers: [ + FormBuilder, + { provide: NativeWindowService, useFactory: NativeWindowMockFactory }, + { provide: ExternalLoginService, useValue: externalLoginServiceSpy }, + { provide: EPersonDataService, useValue: epersonDataServiceSpy }, + { provide: NotificationsService, useValue: notificationServiceSpy }, + { provide: AuthService, useValue: authServiceSpy }, + { provide: TranslateService, useValue: translateServiceStub }, + { provide: HardRedirectService, useValue: hardRedirectService }, + ], + imports: [ + CommonModule, + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock, + }, + }), + ReactiveFormsModule + ], + schemas: [NO_ERRORS_SCHEMA], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(ConfirmEmailComponent); + component = fixture.componentInstance; + component.registrationData = Object.assign(new Registration(), { + id: '123', + email: 'test@example.com', + netId: 'test-netid', + registrationMetadata: {}, + registrationType: AuthMethodType.Orcid, + }); + component.token = 'test-token'; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should show email from registration data', () => { + fixture.detectChanges(); + const emailInput = fixture.debugElement.query(By.css('input[id=email]')); + expect(emailInput).toBeTruthy(); + expect(emailInput.nativeElement.value).toBe('test@example.com'); + }); + + describe('submitForm', () => { + it('should call postCreateAccountFromToken if email is confirmed', () => { + component.emailForm.setValue({ email: 'test@example.com' }); + spyOn(component as any, 'postCreateAccountFromToken'); + component.submitForm(); + expect( + (component as any).postCreateAccountFromToken + ).toHaveBeenCalledWith('test-token', component.registrationData); + }); + + it('should call patchUpdateRegistration if email is not confirmed', () => { + component.emailForm.setValue({ email: 'new-email@example.com' }); + spyOn(component as any, 'patchUpdateRegistration'); + component.submitForm(); + expect((component as any).patchUpdateRegistration).toHaveBeenCalledWith([ + 'new-email@example.com', + ]); + }); + + it('should not call any methods if form is invalid', () => { + component.emailForm.setValue({ email: 'invalid-email' }); + spyOn(component as any, 'postCreateAccountFromToken'); + spyOn(component as any, 'patchUpdateRegistration'); + component.submitForm(); + expect( + (component as any).postCreateAccountFromToken + ).not.toHaveBeenCalled(); + expect((component as any).patchUpdateRegistration).not.toHaveBeenCalled(); + }); + }); + + describe('postCreateAccountFromToken', () => { + it('should call NotificationsService.error if the registration data does not have a netId', () => { + component.registrationData.netId = undefined; + (component as any).postCreateAccountFromToken('test-token', component.registrationData); + expect(notificationServiceSpy.error).toHaveBeenCalled(); + }); + + it('should call EPersonDataService.createEPersonForToken and ExternalLoginService.getExternalAuthLocation if the registration data has a netId', () => { + externalLoginServiceSpy.getExternalAuthLocation.and.returnValue(of('test-location')); + authServiceSpy.getRedirectUrl.and.returnValue(of('/test-redirect')); + authServiceSpy.getExternalServerRedirectUrl.and.returnValue('test-external-url'); + epersonDataServiceSpy.createEPersonForToken.and.returnValue(createSuccessfulRemoteDataObject$(new EPerson())); + (component as any).postCreateAccountFromToken('test-token', component.registrationData); + expect(epersonDataServiceSpy.createEPersonForToken).toHaveBeenCalled(); + expect(externalLoginServiceSpy.getExternalAuthLocation).toHaveBeenCalledWith(AuthMethodType.Orcid); + expect(authServiceSpy.getRedirectUrl).toHaveBeenCalled(); + expect(authServiceSpy.setRedirectUrl).toHaveBeenCalledWith('/profile'); + expect(authServiceSpy.getExternalServerRedirectUrl).toHaveBeenCalledWith(MockWindow.origin,'/test-redirect', 'test-location'); + expect(hardRedirectService.redirect).toHaveBeenCalledWith('test-external-url'); + }); + }); + + afterEach(() => { + fixture.destroy(); + }); +}); 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 0000000000..acc59e8f55 --- /dev/null +++ b/src/app/external-log-in/email-confirmation/confirm-email/confirm-email.component.ts @@ -0,0 +1,160 @@ +import { ChangeDetectionStrategy, Component, Inject, Input, OnDestroy, OnInit } from '@angular/core'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { ExternalLoginService } from '../../services/external-login.service'; +import { TranslateService } from '@ngx-translate/core'; +import isEqual from 'lodash/isEqual'; +import { combineLatest, Subscription, take } from 'rxjs'; +import { AuthService } from '../../../core/auth/auth.service'; +import { EPersonDataService } from '../../../core/eperson/eperson-data.service'; +import { EPerson } from '../../../core/eperson/models/eperson.model'; +import { HardRedirectService } from '../../../core/services/hard-redirect.service'; +import { getFirstCompletedRemoteData, getRemoteDataPayload } from '../../../core/shared/operators'; +import { Registration } from '../../../core/shared/registration.model'; +import { hasNoValue, hasValue } from '../../../shared/empty.util'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { NativeWindowRef, NativeWindowService } from '../../../core/services/window.service'; + +@Component({ + selector: 'ds-confirm-email', + templateUrl: './confirm-email.component.html', + styleUrls: ['./confirm-email.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class ConfirmEmailComponent implements OnInit, OnDestroy { + /** + * The form containing the email input + */ + emailForm: FormGroup; + /** + * The registration data object + */ + @Input() registrationData: Registration; + + /** + * The token to be used to confirm the registration + */ + @Input() token: string; + /** + * The subscriptions to unsubscribe from + */ + subs: Subscription[] = []; + + externalLocation: string; + + constructor( + @Inject(NativeWindowService) protected _window: NativeWindowRef, + private formBuilder: FormBuilder, + private externalLoginService: ExternalLoginService, + private epersonDataService: EPersonDataService, + private notificationService: NotificationsService, + private translate: TranslateService, + private authService: AuthService, + private hardRedirectService: HardRedirectService, + ) { + } + + ngOnInit() { + this.emailForm = this.formBuilder.group({ + email: [this.registrationData.email, [Validators.required, Validators.email]] + }); + } + + + /** + * Submits the email form and performs appropriate actions based on the form's validity and user input. + * If the form is valid and the confirmed email matches the registration email, calls the postCreateAccountFromToken method with the token and registration data. + * If the form is valid but the confirmed email does not match the registration email, calls the patchUpdateRegistration method with the confirmed email. + */ + submitForm() { + this.emailForm.markAllAsTouched(); + if (this.emailForm.valid) { + const confirmedEmail = this.emailForm.get('email').value; + if (confirmedEmail && isEqual(this.registrationData.email, confirmedEmail.trim())) { + this.postCreateAccountFromToken(this.token, this.registrationData); + } else { + this.patchUpdateRegistration([confirmedEmail]); + } + } + } + + /** + * Sends a PATCH request to update the user's registration with the given values. + * @param values - The values to update the user's registration with. + * @returns An Observable that emits the updated registration data. + */ + private patchUpdateRegistration(values: string[]) { + this.subs.push( + this.externalLoginService.patchUpdateRegistration(values, 'email', this.registrationData.id, this.token, 'replace') + .pipe(getRemoteDataPayload()) + .subscribe()); + } + + /** + * Creates a new user from a given token and registration data. + * Based on the registration data, the user will be created with the following properties: + * - email: the email address from the registration data + * - metadata: all metadata values from the registration data, except for the email metadata key (ePerson object does not have an email metadata field) + * - canLogIn: true + * - requireCertificate: false + * @param token The token used to create the user. + * @param registrationData The registration data used to create the user. + * @returns An Observable that emits a boolean indicating whether the user creation was successful. + */ + private postCreateAccountFromToken( + token: string, + registrationData: Registration + ) { + // check if the netId is present + // in order to create an account, the netId is required (since the user is created without a password) + if (hasNoValue(this.registrationData.netId)) { + this.notificationService.error(this.translate.get('external-login-page.confirm-email.create-account.notifications.error.no-netId')); + return; + } + + const metadataValues = {}; + for (const [key, value] of Object.entries(registrationData.registrationMetadata)) { + // exclude the email metadata key, since the ePerson object does not have an email metadata field + if (hasValue(value[0]?.value) && key !== 'email') { + metadataValues[key] = value; + } + } + const eperson = new EPerson(); + eperson.email = registrationData.email; + eperson.netid = registrationData.netId; + eperson.metadata = metadataValues; + eperson.canLogIn = true; + eperson.requireCertificate = false; + eperson.selfRegistered = true; + this.subs.push( + combineLatest([ + this.epersonDataService.createEPersonForToken(eperson, token).pipe( + getFirstCompletedRemoteData(), + ), + this.externalLoginService.getExternalAuthLocation(this.registrationData.registrationType), + this.authService.getRedirectUrl().pipe(take(1)) + ]) + .subscribe(([rd, location, redirectRoute]) => { + if (rd.hasFailed) { + this.notificationService.error( + this.translate.get('external-login-page.provide-email.create-account.notifications.error.header'), + this.translate.get('external-login-page.provide-email.create-account.notifications.error.content') + ); + } else if (rd.hasSucceeded) { + // set Redirect URL to User profile, so the user is redirected to the profile page after logging in + this.authService.setRedirectUrl('/profile'); + const externalServerUrl = this.authService.getExternalServerRedirectUrl( + this._window.nativeWindow.origin, + redirectRoute, + location + ); + // redirect to external registration type authentication url + this.hardRedirectService.redirect(externalServerUrl); + } + }) + ); + } + + ngOnDestroy(): void { + this.subs.filter(sub => hasValue(sub)).forEach((sub) => sub.unsubscribe()); + } +} 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 0000000000..3d79b16a4e --- /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 0000000000..e69de29bb2 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 0000000000..da4e5416d5 --- /dev/null +++ b/src/app/external-log-in/email-confirmation/confirmation-sent/confirmation-sent.component.spec.ts @@ -0,0 +1,63 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ConfirmationSentComponent } from './confirmation-sent.component'; +import { CommonModule } from '@angular/common'; +import { CUSTOM_ELEMENTS_SCHEMA, EventEmitter } from '@angular/core'; +import { TranslateLoader, TranslateModule, TranslateService } from '@ngx-translate/core'; +import { of } from 'rxjs'; +import { TranslateLoaderMock } from '../../../shared/mocks/translate-loader.mock'; + +describe('ConfirmationSentComponent', () => { + let component: ConfirmationSentComponent; + let fixture: ComponentFixture; + let compiledTemplate: HTMLElement; + + const translateServiceStub = { + get: () => of('Mocked Translation Text'), + instant: (key: any) => 'Mocked Translation Text', + onLangChange: new EventEmitter(), + onTranslationChange: new EventEmitter(), + onDefaultLangChange: new EventEmitter() + }; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ ConfirmationSentComponent ], + providers: [ + { provide: TranslateService, useValue: translateServiceStub }, + ], + imports: [ + CommonModule, + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock + } + }), + ], + schemas: [CUSTOM_ELEMENTS_SCHEMA] + }) + .compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(ConfirmationSentComponent); + component = fixture.componentInstance; + compiledTemplate = fixture.nativeElement; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should render translated header', () => { + const headerElement = compiledTemplate.querySelector('h4'); + expect(headerElement.textContent).toContain('Mocked Translation Text'); + }); + + it('should render translated info paragraph', () => { + const infoParagraphElement = compiledTemplate.querySelector('p'); + expect(infoParagraphElement.innerHTML).toBeTruthy(); + }); +}); 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 0000000000..2f82991c0d --- /dev/null +++ b/src/app/external-log-in/email-confirmation/confirmation-sent/confirmation-sent.component.ts @@ -0,0 +1,9 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core'; + +@Component({ + selector: 'ds-confirmation-sent', + templateUrl: './confirmation-sent.component.html', + styleUrls: ['./confirmation-sent.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class ConfirmationSentComponent { } 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 0000000000..46e804e1c2 --- /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 0000000000..e69de29bb2 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 0000000000..a346bfa930 --- /dev/null +++ b/src/app/external-log-in/email-confirmation/provide-email/provide-email.component.spec.ts @@ -0,0 +1,62 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ProvideEmailComponent } from './provide-email.component'; +import { FormBuilder } from '@angular/forms'; +import { CommonModule } from '@angular/common'; +import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; +import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; +import { ExternalLoginService } from '../../services/external-login.service'; +import { TranslateLoaderMock } from '../../../shared/mocks/translate-loader.mock'; + +describe('ProvideEmailComponent', () => { + let component: ProvideEmailComponent; + let fixture: ComponentFixture; + let externalLoginServiceSpy: jasmine.SpyObj; + + beforeEach(async () => { + const externalLoginService = jasmine.createSpyObj('ExternalLoginService', ['patchUpdateRegistration']); + + await TestBed.configureTestingModule({ + declarations: [ ProvideEmailComponent ], + providers: [ + FormBuilder, + { provide: ExternalLoginService, useValue: externalLoginService }, + ], + imports: [ + CommonModule, + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock + } + }), + ], + schemas: [CUSTOM_ELEMENTS_SCHEMA] + }) + .compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(ProvideEmailComponent); + component = fixture.componentInstance; + externalLoginServiceSpy = TestBed.inject(ExternalLoginService) as jasmine.SpyObj; + 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 0000000000..4e3e220ece --- /dev/null +++ b/src/app/external-log-in/email-confirmation/provide-email/provide-email.component.ts @@ -0,0 +1,56 @@ +import { ChangeDetectionStrategy, Component, Input, OnDestroy } from '@angular/core'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { ExternalLoginService } from '../../services/external-login.service'; +import { Subscription } from 'rxjs'; +import { hasValue } from '../../../shared/empty.util'; + +@Component({ + selector: 'ds-provide-email', + templateUrl: './provide-email.component.html', + styleUrls: ['./provide-email.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ProvideEmailComponent implements OnDestroy { + /** + * The form group for the email input + */ + emailForm: FormGroup; + /** + * The registration id + */ + @Input() registrationId: string; + /** + * The token from the URL + */ + @Input() token: string; + /** + * The subscriptions to unsubscribe from + */ + subs: Subscription[] = []; + + constructor( + private formBuilder: FormBuilder, + private externalLoginService: ExternalLoginService, + ) { + this.emailForm = this.formBuilder.group({ + email: ['', [Validators.required, Validators.email]], + }); + } + + /** + * Updates the user's email in the registration data. + * @returns void + */ + submitForm() { + this.emailForm.markAllAsTouched(); + if (this.emailForm.valid) { + const email = this.emailForm.get('email').value; + this.subs.push(this.externalLoginService.patchUpdateRegistration([email], 'email', this.registrationId, this.token, 'add') + .subscribe()); + } + } + + ngOnDestroy(): void { + this.subs.filter(sub => hasValue(sub)).forEach((sub) => sub.unsubscribe()); + } +} 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 0000000000..099a37bd62 --- /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 0000000000..e69de29bb2 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 0000000000..9f29a41dfc --- /dev/null +++ b/src/app/external-log-in/external-log-in/external-log-in.component.spec.ts @@ -0,0 +1,106 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ExternalLogInComponent } from './external-log-in.component'; +import { TranslateModule, TranslateService } from '@ngx-translate/core'; +import { CommonModule } from '@angular/common'; +import { EventEmitter } from '@angular/core'; +import { By } from '@angular/platform-browser'; +import { of as observableOf } from 'rxjs'; +import { FormBuilder } from '@angular/forms'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { AuthRegistrationType } from '../../core/auth/models/auth.registration-type'; +import { MetadataValue } from '../../core/shared/metadata.models'; +import { AuthService } from '../../core/auth/auth.service'; +import { AuthServiceMock } from '../../shared/mocks/auth.service.mock'; +import { Registration } from '../../core/shared/registration.model'; +import { OrcidConfirmationComponent } from '../registration-types/orcid-confirmation/orcid-confirmation.component'; +import { BrowserOnlyPipe } from '../../shared/utils/browser-only.pipe'; + +describe('ExternalLogInComponent', () => { + let component: ExternalLogInComponent; + let fixture: ComponentFixture; + let modalService: NgbModal = jasmine.createSpyObj('modalService', ['open']); + + const registrationDataMock = { + id: '3', + email: 'user@institution.edu', + user: '028dcbb8-0da2-4122-a0ea-254be49ca107', + registrationType: AuthRegistrationType.Orcid, + netId: '0000-1111-2222-3333', + registrationMetadata: { + 'eperson.firstname': [ + Object.assign(new MetadataValue(), { + value: 'User 1', + language: null, + authority: '', + confidence: -1, + place: -1, + }), + ], + } + }; + const translateServiceStub = { + get: () => observableOf('Info Text'), + instant: (key: any) => 'Info Text', + onLangChange: new EventEmitter(), + onTranslationChange: new EventEmitter(), + onDefaultLangChange: new EventEmitter() + }; + + beforeEach(() => + TestBed.configureTestingModule({ + imports: [CommonModule, TranslateModule.forRoot({})], + declarations: [BrowserOnlyPipe, ExternalLogInComponent, OrcidConfirmationComponent], + providers: [ + { provide: TranslateService, useValue: translateServiceStub }, + { provide: AuthService, useValue: new AuthServiceMock() }, + { provide: NgbModal, useValue: modalService }, + FormBuilder + ] + }).compileComponents() + ); + + beforeEach(() => { + fixture = TestBed.createComponent(ExternalLogInComponent); + component = fixture.componentInstance; + component.registrationData = Object.assign(new Registration(), registrationDataMock); + component.registrationType = registrationDataMock.registrationType; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + beforeEach(() => { + component.registrationData = Object.assign(new Registration(), registrationDataMock, { email: 'user@institution.edu' }); + fixture.detectChanges(); + }); + + it('should set registrationType and informationText correctly when email is present', () => { + expect(component.registrationType).toBe(registrationDataMock.registrationType); + expect(component.informationText).toBeDefined(); + }); + + it('should render the template to confirm email when registrationData has email', () => { + const selector = fixture.nativeElement.querySelector('ds-confirm-email'); + const provideEmail = fixture.nativeElement.querySelector('ds-provide-email'); + expect(selector).toBeTruthy(); + expect(provideEmail).toBeNull(); + }); + + it('should display login modal when connect to existing account button is clicked', () => { + const button = fixture.nativeElement.querySelector('button.btn-primary'); + button.click(); + expect(modalService.open).toHaveBeenCalled(); + }); + + it('should render the template with the translated informationText', () => { + component.informationText = 'Info Text'; + fixture.detectChanges(); + const infoText = fixture.debugElement.query(By.css('[data-test="info-text"]')); + expect(infoText.nativeElement.innerHTML).toContain('Info Text'); + }); +}); + + 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 0000000000..cf0a96f54a --- /dev/null +++ b/src/app/external-log-in/external-log-in/external-log-in.component.ts @@ -0,0 +1,149 @@ +import { ChangeDetectionStrategy, Component, Injector, Input, OnDestroy, OnInit, } from '@angular/core'; +import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'; +import { TranslateService } from '@ngx-translate/core'; +import { AuthService } from '../../core/auth/auth.service'; +import { AuthMethodType } from '../../core/auth/models/auth.method-type'; +import { AuthRegistrationType } from '../../core/auth/models/auth.registration-type'; +import { Registration } from '../../core/shared/registration.model'; +import { hasValue, isEmpty } from '../../shared/empty.util'; +import { getExternalLoginConfirmationType } from '../decorators/external-log-in.methods-decorator'; +import { AlertType } from '../../shared/alert/alert-type'; + +@Component({ + selector: 'ds-external-log-in', + templateUrl: './external-log-in.component.html', + styleUrls: ['./external-log-in.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ExternalLogInComponent implements OnInit, OnDestroy { + /** + * The AlertType enumeration for access in the component's template + * @type {AlertType} + */ + public AlertTypeEnum = AlertType; + + /** + * The type of registration type to be confirmed + */ + registrationType: AuthRegistrationType; + /** + * The registration data object + */ + @Input() registrationData: Registration; + /** + * The token to be used to confirm the registration + * @memberof ExternalLogInComponent + */ + @Input() token: string; + /** + * The information text to be displayed, + * depending on the registration type and the presence of an email + * @memberof ExternalLogInComponent + */ + public informationText = ''; + /** + * Injector to inject a registration data to the component with the @Input registrationType + * @type {Injector} + */ + public objectInjector: Injector; + + /** + * Reference to NgbModal + */ + public modalRef: NgbModalRef; + + /** + * Authentication method related to registration type + */ + relatedAuthMethod: AuthMethodType; + + constructor( + private injector: Injector, + private translate: TranslateService, + private modalService: NgbModal, + private authService: AuthService, + ) { } + + /** + * Provide the registration data object to the objectInjector. + * Generate the information text to be displayed. + */ + ngOnInit(): void { + this.objectInjector = Injector.create({ + providers: [ + { + provide: 'registrationDataProvider', + useFactory: () => this.registrationData, + deps: [], + }, + ], + parent: this.injector, + }); + this.registrationType = this.registrationData?.registrationType ?? null; + this.relatedAuthMethod = isEmpty(this.registrationType) ? null : this.registrationType.replace('VALIDATION_', '').toLocaleLowerCase() as AuthMethodType; + this.informationText = hasValue(this.registrationData?.email) + ? this.generateInformationTextWhenEmail(this.registrationType) + : this.generateInformationTextWhenNOEmail(this.registrationType); + } + + /** + * Generate the information text to be displayed when the user has no email + * @param authMethod the registration type + */ + private generateInformationTextWhenNOEmail(authMethod: string): string { + if (authMethod) { + const authMethodUppercase = authMethod.toUpperCase(); + return this.translate.instant('external-login.noEmail.informationText', { + authMethod: authMethodUppercase, + }); + } + } + + /** + * Generate the information text to be displayed when the user has an email + * @param authMethod the registration type + */ + private generateInformationTextWhenEmail(authMethod: string): string { + if (authMethod) { + const authMethodUppercase = authMethod.toUpperCase(); + return this.translate.instant( + 'external-login.haveEmail.informationText', + { authMethod: authMethodUppercase } + ); + } + } + + /** + * Get the registration type to be rendered + */ + getExternalLoginConfirmationType() { + return getExternalLoginConfirmationType(this.registrationType); + } + + /** + * Opens the login modal and sets the redirect URL to '/review-account'. + * On modal dismissed/closed, the redirect URL is cleared. + * @param content - The content to be displayed in the modal. + */ + openLoginModal(content: any) { + setTimeout(() => { + this.authService.setRedirectUrl(`/review-account/${this.token}`); + }, 100); + this.modalRef = this.modalService.open(content); + + this.modalRef.dismissed.subscribe(() => { + this.clearRedirectUrl(); + }); + } + + /** + * Clears the redirect URL stored in the authentication service. + */ + clearRedirectUrl() { + this.authService.clearRedirectUrl(); + } + + ngOnDestroy(): void { + this.modalRef?.close(); + } +} diff --git a/src/app/external-log-in/external-login.module.ts b/src/app/external-log-in/external-login.module.ts new file mode 100644 index 0000000000..b0d75aa730 --- /dev/null +++ b/src/app/external-log-in/external-login.module.ts @@ -0,0 +1,35 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { ConfirmEmailComponent } from './email-confirmation/confirm-email/confirm-email.component'; +import { ConfirmationSentComponent } from './email-confirmation/confirmation-sent/confirmation-sent.component'; +import { ProvideEmailComponent } from './email-confirmation/provide-email/provide-email.component'; +import { ExternalLogInComponent } from './external-log-in/external-log-in.component'; +import { OrcidConfirmationComponent } from './registration-types/orcid-confirmation/orcid-confirmation.component'; +import { SharedModule } from '../shared/shared.module'; + +const COMPONENTS = [ + ExternalLogInComponent, + ProvideEmailComponent, + ConfirmEmailComponent, + ConfirmationSentComponent, +]; + +const ENTRY_COMPONENTS = [OrcidConfirmationComponent]; + +@NgModule({ + declarations: [...COMPONENTS, ...ENTRY_COMPONENTS], + imports: [CommonModule, SharedModule], + exports: [...COMPONENTS, ...ENTRY_COMPONENTS], +}) +export class ExternalLoginModule { + /** + * NOTE: this method allows to resolve issue with components that using a custom decorator + * which are not loaded during SSR otherwise + */ + static withEntryComponents() { + return { + ngModule: ExternalLoginModule, + providers: ENTRY_COMPONENTS.map((component) => ({ provide: component })), + }; + } +} 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 0000000000..5a0289697f --- /dev/null +++ b/src/app/external-log-in/guards/registration-token.guard.spec.ts @@ -0,0 +1,79 @@ +import { TestBed } from '@angular/core/testing'; +import { RegistrationTokenGuard } from './registration-token.guard'; +import { ActivatedRoute, convertToParamMap, Params, Router } from '@angular/router'; +import { of as observableOf } from 'rxjs'; +import { RouterMock } from '../../shared/mocks/router.mock'; +import { Registration } from '../../core/shared/registration.model'; +import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; +import { EPerson } from '../../core/eperson/models/eperson.model'; +import { EpersonRegistrationService } from '../../core/data/eperson-registration.service'; +import { AuthService } from '../../core/auth/auth.service'; + +describe('RegistrationTokenGuard', () => { + let guard: RegistrationTokenGuard; + const route = new RouterMock(); + const registrationWithGroups = Object.assign(new Registration(), + { + email: 'test@email.org', + token: 'test-token', + }); + const epersonRegistrationService = jasmine.createSpyObj('epersonRegistrationService', { + searchByTokenAndHandleError: createSuccessfulRemoteDataObject$(registrationWithGroups) + }); + const authService = { + getAuthenticatedUserFromStore: () => observableOf(ePerson), + setRedirectUrl: () => { + return true; + } + } as any; + const ePerson = Object.assign(new EPerson(), { + id: 'test-eperson', + uuid: 'test-eperson' + }); + beforeEach(() => { + const paramObject: Params = {}; + paramObject.token = '1234'; + TestBed.configureTestingModule({ + providers: [{provide: Router, useValue: route}, + { + provide: ActivatedRoute, + useValue: { + queryParamMap: observableOf(convertToParamMap(paramObject)) + }, + }, + {provide: EpersonRegistrationService, useValue: epersonRegistrationService}, + {provide: AuthService, useValue: authService} + ] + }); + guard = TestBed.get(RegistrationTokenGuard); + }); + + it('should be created', () => { + expect(guard).toBeTruthy(); + }); + describe('based on the response of "searchByToken have', () => { + it('can activate must return true when registration data includes groups', () => { + (guard.canActivate({ params: { token: '123456789' } } as any, {} as any) as any) + .subscribe( + (canActivate) => { + expect(canActivate).toEqual(true); + } + ); + }); + it('can activate must return false when registration data includes groups', () => { + const registrationWithDifferentUsedFromLoggedInt = Object.assign(new Registration(), + { + email: 't1@email.org', + token: 'test-token', + }); + epersonRegistrationService.searchByTokenAndHandleError.and.returnValue(observableOf(registrationWithDifferentUsedFromLoggedInt)); + (guard.canActivate({ params: { token: '123456789' } } as any, {} as any) as any) + .subscribe( + (canActivate) => { + expect(canActivate).toEqual(false); + } + ); + }); + + }); +}); 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 0000000000..2135e3a1ae --- /dev/null +++ b/src/app/external-log-in/guards/registration-token.guard.ts @@ -0,0 +1,49 @@ +import { Injectable } from '@angular/core'; +import { ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot, } from '@angular/router'; +import { map, Observable, of } from 'rxjs'; +import { EpersonRegistrationService } from '../../core/data/eperson-registration.service'; +import { getFirstCompletedRemoteData } from '../../core/shared/operators'; +import { Registration } from '../../core/shared/registration.model'; +import { RemoteData } from '../../core/data/remote-data'; +import { hasValue } from '../../shared/empty.util'; + +@Injectable({ + providedIn: 'root', +}) +export class RegistrationTokenGuard implements CanActivate { + constructor( + private router: Router, + private epersonRegistrationService: EpersonRegistrationService + ) { } + + /** + * Determines if a user can activate a route based on the registration token. + * @param route - The activated route snapshot. + * @param state - The router state snapshot. + * @returns A value indicating if the user can activate the route. + */ + canActivate( + route: ActivatedRouteSnapshot, + state: RouterStateSnapshot + ): Observable { + if (route.params.token) { + return this.epersonRegistrationService + .searchByTokenAndHandleError(route.params.token) + .pipe( + getFirstCompletedRemoteData(), + map( + (data: RemoteData) => { + if (data.hasSucceeded && hasValue(data)) { + return true; + } else { + this.router.navigate(['/404']); + } + } + ) + ); + } else { + this.router.navigate(['/404']); + return of(false); + } + } +} 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 0000000000..51f5fc4434 --- /dev/null +++ b/src/app/external-log-in/models/registration-data.mock.model.ts @@ -0,0 +1,45 @@ +import { Registration } from '../../core/shared/registration.model'; +import { AuthMethodType } from '../../core/auth/models/auth.method-type'; +import { MetadataValue } from '../../core/shared/metadata.models'; + +export const mockRegistrationDataModel: Registration = Object.assign( + new Registration(), + { + id: '3', + email: 'user@institution.edu', + user: '028dcbb8-0da2-4122-a0ea-254be49ca107', + registrationType: AuthMethodType.Orcid, + netId: '0000-1111-2222-3333', + registrationMetadata: { + 'eperson.firstname': [ + Object.assign(new MetadataValue(), { + value: 'User', + language: null, + authority: '', + confidence: -1, + place: -1, + overrides: 'User', + }), + ], + 'eperson.lastname': [ + Object.assign(new MetadataValue(), { + value: 'Power', + language: null, + authority: '', + confidence: -1, + place: -1, + }), + ], + 'email': [ + { + value: 'power-user@orcid.org', + language: null, + authority: '', + confidence: -1, + place: -1, + overrides: 'power-user@dspace.org', + }, + ], + }, + } +); 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 0000000000..df5bb454dd --- /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 0000000000..e69de29bb2 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 0000000000..de6ec49cf1 --- /dev/null +++ b/src/app/external-log-in/registration-types/orcid-confirmation/orcid-confirmation.component.spec.ts @@ -0,0 +1,69 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { OrcidConfirmationComponent } from './orcid-confirmation.component'; +import { FormBuilder, FormGroup } from '@angular/forms'; +import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { Registration } from 'src/app/core/shared/registration.model'; +import { mockRegistrationDataModel } from '../../models/registration-data.mock.model'; +import { BrowserOnlyMockPipe } from '../../../shared/testing/browser-only-mock.pipe'; +import { TranslateLoaderMock } from '../../../shared/mocks/translate-loader.mock'; + +describe('OrcidConfirmationComponent', () => { + let component: OrcidConfirmationComponent; + let fixture: ComponentFixture; + let model: Registration; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ + OrcidConfirmationComponent, + BrowserOnlyMockPipe, + ], + providers: [ + FormBuilder, + { provide: 'registrationDataProvider', useValue: mockRegistrationDataModel }, + ], + imports: [ + CommonModule, + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock + } + }), + ], + schemas: [NO_ERRORS_SCHEMA] + }) + .compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(OrcidConfirmationComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should initialize the form with disabled fields', () => { + expect(component.form).toBeInstanceOf(FormGroup); + expect(component.form.controls.netId.disabled).toBeTrue(); + expect(component.form.controls.firstname.disabled).toBeTrue(); + expect(component.form.controls.lastname.disabled).toBeTrue(); + expect(component.form.controls.email.disabled).toBeTrue(); + }); + + + it('should initialize the form with null email as an empty string', () => { + component.registratioData.email = null; + component.ngOnInit(); + fixture.detectChanges(); + const emailFormControl = component.form.get('email'); + expect(emailFormControl.value).toBe(''); + }); + +}); 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 0000000000..52973e8267 --- /dev/null +++ b/src/app/external-log-in/registration-types/orcid-confirmation/orcid-confirmation.component.ts @@ -0,0 +1,60 @@ +import { ChangeDetectionStrategy, Component, Inject, OnInit } from '@angular/core'; +import { FormBuilder, FormGroup } from '@angular/forms'; +import { AuthRegistrationType } from '../../../core/auth/models/auth.registration-type'; +import { Registration } from '../../../core/shared/registration.model'; +import { renderExternalLoginConfirmationFor } from '../../decorators/external-log-in.methods-decorator'; +import { ExternalLoginMethodEntryComponent } from '../../decorators/external-login-method-entry.component'; + +@Component({ + selector: 'ds-orcid-confirmation', + templateUrl: './orcid-confirmation.component.html', + styleUrls: ['./orcid-confirmation.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +@renderExternalLoginConfirmationFor(AuthRegistrationType.Orcid) +export class OrcidConfirmationComponent extends ExternalLoginMethodEntryComponent implements OnInit { + + /** + * The form containing the user's data + */ + public form: FormGroup; + + /** + * @param injectedRegistrationDataObject Registration object provided + * @param formBuilder To build the form + */ + constructor( + @Inject('registrationDataProvider') protected injectedRegistrationDataObject: Registration, + private formBuilder: FormBuilder + ) { + super(injectedRegistrationDataObject); + } + + /** + * Initialize the form with disabled fields + */ + ngOnInit(): void { + this.form = this.formBuilder.group({ + netId: [{ value: this.registratioData.netId, disabled: true }], + firstname: [{ value: this.getFirstname(), disabled: true }], + lastname: [{ value: this.getLastname(), disabled: true }], + email: [{ value: this.registratioData?.email || '', disabled: true }], // email can be null + }); + } + + /** + * Get the firstname of the user from the registration metadata + * @returns the firstname of the user + */ + private getFirstname(): string { + return this.registratioData.registrationMetadata?.['eperson.firstname']?.[0]?.value || ''; + } + + /** + * Get the lastname of the user from the registration metadata + * @returns the lastname of the user + */ + private getLastname(): string { + return this.registratioData.registrationMetadata?.['eperson.lastname']?.[0]?.value || ''; + } +} 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 0000000000..08049ee427 --- /dev/null +++ b/src/app/external-log-in/resolvers/registration-data.resolver.spec.ts @@ -0,0 +1,46 @@ +import { TestBed } from '@angular/core/testing'; + +import { RegistrationDataResolver } from './registration-data.resolver'; +import { ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router'; +import { EpersonRegistrationService } from '../../core/data/eperson-registration.service'; +import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; +import { Registration } from '../../core/shared/registration.model'; + +describe('RegistrationDataResolver', () => { + let resolver: RegistrationDataResolver; + let epersonRegistrationServiceSpy: jasmine.SpyObj; + 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 0000000000..b41c4a37cc --- /dev/null +++ b/src/app/external-log-in/resolvers/registration-data.resolver.ts @@ -0,0 +1,38 @@ +import { Injectable } from '@angular/core'; +import { ActivatedRouteSnapshot, Resolve, RouterStateSnapshot, } from '@angular/router'; +import { Observable } from 'rxjs'; +import { hasValue } from '../../shared/empty.util'; +import { getFirstCompletedRemoteData } from '../../core/shared/operators'; +import { Registration } from '../../core/shared/registration.model'; +import { RemoteData } from '../../core/data/remote-data'; +import { EpersonRegistrationService } from '../../core/data/eperson-registration.service'; + +@Injectable({ + providedIn: 'root', +}) +/** + * Resolver for retrieving registration data based on a token. + */ +export class RegistrationDataResolver implements Resolve> { + + /** + * 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 0000000000..525137e33b --- /dev/null +++ b/src/app/external-log-in/services/external-login.service.spec.ts @@ -0,0 +1,78 @@ +import { TestBed } from '@angular/core/testing'; + +import { ExternalLoginService } from './external-login.service'; +import { TranslateService } from '@ngx-translate/core'; +import { of as observableOf } from 'rxjs'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { Router } from '@angular/router'; +import { Store } from '@ngrx/store'; +import { EpersonRegistrationService } from '../../core/data/eperson-registration.service'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { RemoteData } from '../../core/data/remote-data'; +import { Registration } from '../../core/shared/registration.model'; +import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub'; +import { RouterMock } from '../../shared/mocks/router.mock'; +import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; + +describe('ExternalLoginService', () => { + let service: ExternalLoginService; + let epersonRegistrationService; + let router: any; + let notificationService; + let translate; + + const values = ['value1', 'value2']; + const field = 'field1'; + const registrationId = 'registrationId1'; + const token = 'token1'; + const operation = 'add'; + + beforeEach(() => { + epersonRegistrationService = jasmine.createSpyObj('epersonRegistrationService', { + patchUpdateRegistration: createSuccessfulRemoteDataObject$(new Registration) + }); + router = new RouterMock(); + notificationService = new NotificationsServiceStub(); + translate = jasmine.createSpyObj('TranslateService', ['get']); + + TestBed.configureTestingModule({ + providers: [ + ExternalLoginService, + { provide: EpersonRegistrationService, useValue: epersonRegistrationService }, + { provide: Router, useValue: router }, + { provide: NotificationsService, useValue: notificationService }, + { provide: TranslateService, useValue: translate }, + { provide: Store, useValue: {} }, + ], + schemas: [NO_ERRORS_SCHEMA] + }); + service = TestBed.inject(ExternalLoginService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should call epersonRegistrationService.patchUpdateRegistration with the correct parameters', () => { + epersonRegistrationService.patchUpdateRegistration.and.returnValue(observableOf({} as RemoteData)); + service.patchUpdateRegistration(values, field, registrationId, token, operation); + expect(epersonRegistrationService.patchUpdateRegistration).toHaveBeenCalledWith(values, field, registrationId, token, operation); + }); + + it('should navigate to /email-confirmation if the remote data has succeeded', () => { + const remoteData = { hasSucceeded: true } as RemoteData; + epersonRegistrationService.patchUpdateRegistration.and.returnValue(observableOf(remoteData)); + service.patchUpdateRegistration(values, field, registrationId, token, operation).subscribe(() => { + expect((router as any).navigate).toHaveBeenCalledWith(['/email-confirmation']); + }); + }); + + it('should show an error notification if the remote data has failed', () => { + const remoteData = { hasFailed: true } as RemoteData; + epersonRegistrationService.patchUpdateRegistration.and.returnValue(observableOf(remoteData)); + translate.get.and.returnValue(observableOf('error message')); + service.patchUpdateRegistration(values, field, registrationId, token, operation).subscribe(() => { + expect(notificationService.error).toHaveBeenCalledWith('error message'); + }); + }); +}); 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 0000000000..e17282fe40 --- /dev/null +++ b/src/app/external-log-in/services/external-login.service.ts @@ -0,0 +1,65 @@ +import { Injectable } from '@angular/core'; +import { Router } from '@angular/router'; +import { filter, map, Observable } from 'rxjs'; +import { TranslateService } from '@ngx-translate/core'; +import { AuthMethod } from 'src/app/core/auth/models/auth.method'; +import { getAuthenticationMethods } from 'src/app/core/auth/selectors'; +import { select, Store } from '@ngrx/store'; +import { CoreState } from 'src/app/core/core-state.model'; +import { getFirstCompletedRemoteData } from '../../core/shared/operators'; +import { RemoteData } from '../../core/data/remote-data'; +import { NoContent } from '../../core/shared/NoContent.model'; +import { EpersonRegistrationService } from '../../core/data/eperson-registration.service'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; + +@Injectable({ + providedIn: 'root' +}) +export class ExternalLoginService { + + constructor( + private epersonRegistrationService: EpersonRegistrationService, + private router: Router, + private notificationService: NotificationsService, + private translate: TranslateService, + private store: Store, + ) { } + + /** + * 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 => m.authMethodType === 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 0000000000..d3bfcb2b28 --- /dev/null +++ b/src/app/external-login-email-confirmation-page/external-login-email-confirmation-page-routes.ts @@ -0,0 +1,10 @@ +import { Routes } from '@angular/router'; +import { ExternalLoginEmailConfirmationPageComponent } from './external-login-email-confirmation-page.component'; + +export const ROUTES: Routes = [ + { + path: '', + pathMatch: 'full', + component: ExternalLoginEmailConfirmationPageComponent, + }, +]; diff --git a/src/app/external-login-email-confirmation-page/external-login-email-confirmation-page-routing.module.ts b/src/app/external-login-email-confirmation-page/external-login-email-confirmation-page-routing.module.ts new file mode 100644 index 0000000000..0033d1620e --- /dev/null +++ b/src/app/external-login-email-confirmation-page/external-login-email-confirmation-page-routing.module.ts @@ -0,0 +1,17 @@ +import { NgModule } from '@angular/core'; +import { RouterModule, Routes } from '@angular/router'; +import { ExternalLoginEmailConfirmationPageComponent } from './external-login-email-confirmation-page.component'; + +const routes: Routes = [ + { + path: '', + pathMatch: 'full', + component: ExternalLoginEmailConfirmationPageComponent, + }, +]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule], +}) +export class ExternalLoginEmailConfirmationPageRoutingModule {} 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 0000000000..c1cf46032b --- /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 0000000000..e69de29bb2 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 0000000000..60e03b3c51 --- /dev/null +++ b/src/app/external-login-email-confirmation-page/external-login-email-confirmation-page.component.spec.ts @@ -0,0 +1,45 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ExternalLoginEmailConfirmationPageComponent } from './external-login-email-confirmation-page.component'; +import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; +import { TranslateLoaderMock } from '../shared/mocks/translate-loader.mock'; +import { + ConfirmationSentComponent +} from '../external-log-in/email-confirmation/confirmation-sent/confirmation-sent.component'; + +describe('ExternalLoginEmailConfirmationPageComponent', () => { + let component: ExternalLoginEmailConfirmationPageComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ + ExternalLoginEmailConfirmationPageComponent, + ConfirmationSentComponent ], + imports: [ + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock, + }, + }), + ] + }) + .compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(ExternalLoginEmailConfirmationPageComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should render ConfirmationSentComponent', () => { + const compiled = fixture.nativeElement; + expect(compiled.querySelector('ds-confirmation-sent')).toBeTruthy(); + }); +}); 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 0000000000..615de74ea3 --- /dev/null +++ b/src/app/external-login-email-confirmation-page/external-login-email-confirmation-page.component.ts @@ -0,0 +1,8 @@ +import { Component } from '@angular/core'; + +@Component({ + templateUrl: './external-login-email-confirmation-page.component.html', + styleUrls: ['./external-login-email-confirmation-page.component.scss'] +}) +export class ExternalLoginEmailConfirmationPageComponent { +} diff --git a/src/app/external-login-email-confirmation-page/external-login-email-confirmation-page.module.ts b/src/app/external-login-email-confirmation-page/external-login-email-confirmation-page.module.ts new file mode 100644 index 0000000000..bdfb9ca87f --- /dev/null +++ b/src/app/external-login-email-confirmation-page/external-login-email-confirmation-page.module.ts @@ -0,0 +1,21 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +import { + ExternalLoginEmailConfirmationPageRoutingModule +} from './external-login-email-confirmation-page-routing.module'; +import { ExternalLoginEmailConfirmationPageComponent } from './external-login-email-confirmation-page.component'; +import { ExternalLoginModule } from '../external-log-in/external-login.module'; + + +@NgModule({ + declarations: [ + ExternalLoginEmailConfirmationPageComponent + ], + imports: [ + CommonModule, + ExternalLoginEmailConfirmationPageRoutingModule, + ExternalLoginModule, + ] +}) +export class ExternalLoginEmailConfirmationPageModule { } diff --git a/src/app/external-login-page/external-login-page-routing.module.ts b/src/app/external-login-page/external-login-page-routing.module.ts new file mode 100644 index 0000000000..b248115ddb --- /dev/null +++ b/src/app/external-login-page/external-login-page-routing.module.ts @@ -0,0 +1,22 @@ +import { NgModule } from '@angular/core'; +import { RouterModule, Routes } from '@angular/router'; +import { ThemedExternalLoginPageComponent } from './themed-external-login-page.component'; +import { RegistrationDataResolver } from '../external-log-in/resolvers/registration-data.resolver'; +import { RegistrationTokenGuard } from '../external-log-in/guards/registration-token.guard'; + +const routes: Routes = [ + { + path: '', + pathMatch: 'full', + component: ThemedExternalLoginPageComponent, + canActivate: [RegistrationTokenGuard], + resolve: { registrationData: RegistrationDataResolver }, + }, +]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule], + providers: [], +}) +export class ExternalLoginPageRoutingModule {} 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 0000000000..755896211d --- /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 0000000000..e69de29bb2 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 0000000000..3ea6724278 --- /dev/null +++ b/src/app/external-login-page/external-login-page.component.spec.ts @@ -0,0 +1,79 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ExternalLoginPageComponent } from './external-login-page.component'; +import { ActivatedRoute } from '@angular/router'; +import { of } from 'rxjs'; +import { CommonModule } from '@angular/common'; +import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; +import { TranslateLoaderMock } from '../shared/mocks/translate-loader.mock'; +import { Registration } from '../core/shared/registration.model'; + +describe('ExternalLoginPageComponent', () => { + let component: ExternalLoginPageComponent; + let fixture: ComponentFixture; + + const registrationDataMock = { + registrationType: 'orcid', + email: 'test@test.com', + netId: '0000-0000-0000-0000', + user: 'a44d8c9e-9b1f-4e7f-9b1a-5c9d8a0b1f1a', + registrationMetadata: { + 'email': [{ value: 'test@test.com' }], + 'eperson.lastname': [{ value: 'Doe' }], + 'eperson.firstname': [{ value: 'John' }], + }, + }; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ ExternalLoginPageComponent ], + providers: [ + { + provide: ActivatedRoute, + useValue: { + snapshot: { + params: { + token: '1234567890', + }, + }, + data: of(registrationDataMock), + }, + }, + ], + imports: [ + CommonModule, + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock, + }, + }), + ], + }) + .compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(ExternalLoginPageComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should set the token from the query params', () => { + expect(component.token).toEqual('1234567890'); + }); + + it('should display the DsExternalLogIn component when there are no errors', () => { + const registrationData = Object.assign(new Registration(), registrationDataMock); + component.registrationData$ = of(registrationData); + component.token = '1234567890'; + component.hasErrors = false; + fixture.detectChanges(); + const dsExternalLogInComponent = fixture.nativeElement.querySelector('ds-external-log-in'); + expect(dsExternalLogInComponent).toBeTruthy(); + }); +}); 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 0000000000..76ef44ad36 --- /dev/null +++ b/src/app/external-login-page/external-login-page.component.ts @@ -0,0 +1,48 @@ +import { Component, OnInit } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { hasNoValue } from '../shared/empty.util'; +import { first, map, Observable, tap } from 'rxjs'; +import { Registration } from '../core/shared/registration.model'; +import { RemoteData } from '../core/data/remote-data'; +import { AlertType } from '../shared/alert/alert-type'; + +@Component({ + templateUrl: './external-login-page.component.html', + styleUrls: ['./external-login-page.component.scss'], +}) +export class ExternalLoginPageComponent implements OnInit { + /** + * The token used to get the registration data, + * retrieved from the url. + * @memberof ExternalLoginPageComponent + */ + public token: string; + /** + * The registration data of the user. + */ + public registrationData$: Observable; + /** + * 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-page.module.ts b/src/app/external-login-page/external-login-page.module.ts new file mode 100644 index 0000000000..a780805762 --- /dev/null +++ b/src/app/external-login-page/external-login-page.module.ts @@ -0,0 +1,26 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +import { ExternalLoginPageRoutingModule } from './external-login-page-routing.module'; +import { ExternalLoginPageComponent } from './external-login-page.component'; +import { ThemedExternalLoginPageComponent } from './themed-external-login-page.component'; +import { SharedModule } from '../shared/shared.module'; +import { ExternalLoginModule } from '../external-log-in/external-login.module'; + +const COMPONENTS = [ + ExternalLoginPageComponent, + ThemedExternalLoginPageComponent, +]; + +@NgModule({ + declarations: [ + ...COMPONENTS + ], + imports: [ + CommonModule, + ExternalLoginPageRoutingModule, + SharedModule, + ExternalLoginModule + ] +}) +export class ExternalLoginPageModule { } 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 0000000000..4221ba0392 --- /dev/null +++ b/src/app/external-login-page/external-login-routes.ts @@ -0,0 +1,14 @@ +import { Route } from '@angular/router'; +import { ThemedExternalLoginPageComponent } from './themed-external-login-page.component'; +import { RegistrationTokenGuard } from '../external-log-in/guards/registration-token.guard'; +import { RegistrationDataResolver } from '../external-log-in/resolvers/registration-data.resolver'; + +export const ROUTES: Route[] = [ + { + path: '', + pathMatch: 'full', + component: ThemedExternalLoginPageComponent, + canActivate: [RegistrationTokenGuard], + resolve: { registrationData: RegistrationDataResolver }, + }, +]; 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 0000000000..975b9a2f55 --- /dev/null +++ b/src/app/external-login-page/themed-external-login-page.component.ts @@ -0,0 +1,25 @@ +import { Component } from '@angular/core'; +import { ThemedComponent } from '../shared/theme-support/themed.component'; +import { ExternalLoginPageComponent } from './external-login-page.component'; + +/** + * Themed wrapper for ExternalLoginPageComponent + */ +@Component({ + selector: 'ds-themed-external-login-page', + styleUrls: [], + templateUrl: './../shared/theme-support/themed.component.html' +}) +export class ThemedExternalLoginPageComponent extends ThemedComponent { + 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 0000000000..a2d878d352 --- /dev/null +++ b/src/app/external-login-review-account-info-page/external-login-review-account-info-page-routes.ts @@ -0,0 +1,14 @@ +import { ExternalLoginReviewAccountInfoPageComponent } from './external-login-review-account-info-page.component'; +import { ReviewAccountGuard } from './helpers/review-account.guard'; +import { RegistrationDataResolver } from '../external-log-in/resolvers/registration-data.resolver'; +import { Route } from '@angular/router'; + +export const ROUTES: Route[] = [ + { + path: '', + pathMatch: 'full', + component: ExternalLoginReviewAccountInfoPageComponent, + canActivate: [ReviewAccountGuard], + resolve: { registrationData: RegistrationDataResolver }, + }, +] diff --git a/src/app/external-login-review-account-info-page/external-login-review-account-info-page-routing.module.ts b/src/app/external-login-review-account-info-page/external-login-review-account-info-page-routing.module.ts new file mode 100644 index 0000000000..afe0249f07 --- /dev/null +++ b/src/app/external-login-review-account-info-page/external-login-review-account-info-page-routing.module.ts @@ -0,0 +1,21 @@ +import { NgModule } from '@angular/core'; +import { RouterModule, Routes } from '@angular/router'; +import { ExternalLoginReviewAccountInfoPageComponent } from './external-login-review-account-info-page.component'; +import { ReviewAccountGuard } from './helpers/review-account.guard'; +import { RegistrationDataResolver } from '../external-log-in/resolvers/registration-data.resolver'; + +const routes: Routes = [ + { + path: '', + pathMatch: 'full', + component: ExternalLoginReviewAccountInfoPageComponent, + canActivate: [ReviewAccountGuard], + resolve: { registrationData: RegistrationDataResolver }, + }, +]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule], +}) +export class ExternalLoginReviewAccountInfoRoutingModule {} 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 0000000000..831b53ce71 --- /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 0000000000..e69de29bb2 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 0000000000..7ef0e1cac3 --- /dev/null +++ b/src/app/external-login-review-account-info-page/external-login-review-account-info-page.component.spec.ts @@ -0,0 +1,60 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ActivatedRoute } from '@angular/router'; +import { of } from 'rxjs'; +import { ExternalLoginReviewAccountInfoPageComponent } from './external-login-review-account-info-page.component'; +import { mockRegistrationDataModel } from '../external-log-in/models/registration-data.mock.model'; + +describe('ExternalLoginReviewAccountInfoPageComponent', () => { + let component: ExternalLoginReviewAccountInfoPageComponent; + let fixture: ComponentFixture; + + const mockActivatedRoute = { + snapshot: { + params: { + token: '1234567890' + } + }, + data: of({ + registrationData: mockRegistrationDataModel + }) + }; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ExternalLoginReviewAccountInfoPageComponent], + providers: [ + { provide: ActivatedRoute, useValue: mockActivatedRoute } + ] + }) + .compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(ExternalLoginReviewAccountInfoPageComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should set the token from the query params', () => { + expect(component.token).toEqual('1234567890'); + }); + + it('should display review account info component when there are no errors', () => { + component.hasErrors = false; + component.registrationData$ = of(mockRegistrationDataModel); + fixture.detectChanges(); + const reviewAccountInfoComponent = fixture.nativeElement.querySelector('ds-review-account-info'); + expect(reviewAccountInfoComponent).toBeTruthy(); + }); + + it('should display error alert when there are errors', () => { + component.hasErrors = true; + fixture.detectChanges(); + const errorAlertComponent = fixture.nativeElement.querySelector('ds-alert'); + expect(errorAlertComponent).toBeTruthy(); + }); +}); 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 0000000000..92e140eaa4 --- /dev/null +++ b/src/app/external-login-review-account-info-page/external-login-review-account-info-page.component.ts @@ -0,0 +1,47 @@ +import { Component, OnInit } from '@angular/core'; +import { first, map, Observable, tap } from 'rxjs'; +import { ActivatedRoute } from '@angular/router'; +import { hasNoValue } from '../shared/empty.util'; +import { Registration } from '../core/shared/registration.model'; +import { RemoteData } from '../core/data/remote-data'; +import { AlertType } from '../shared/alert/alert-type'; + +@Component({ + templateUrl: './external-login-review-account-info-page.component.html', + styleUrls: ['./external-login-review-account-info-page.component.scss'] +}) +export class ExternalLoginReviewAccountInfoPageComponent implements OnInit { + /** + * The token used to get the registration data + */ + public token: string; + + /** + * The type of alert to show + */ + public AlertTypeEnum = AlertType; + + /** + * The registration data of the user + */ + public registrationData$: Observable; + /** + * 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/external-login-review-account-info-page.module.ts b/src/app/external-login-review-account-info-page/external-login-review-account-info-page.module.ts new file mode 100644 index 0000000000..bcad6db426 --- /dev/null +++ b/src/app/external-login-review-account-info-page/external-login-review-account-info-page.module.ts @@ -0,0 +1,30 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +import { ExternalLoginReviewAccountInfoRoutingModule } from './external-login-review-account-info-page-routing.module'; +import { ExternalLoginReviewAccountInfoPageComponent } from './external-login-review-account-info-page.component'; +import { CompareValuesPipe } from './helpers/compare-values.pipe'; +import { + ThemedExternalLoginReviewAccountInfoPageComponent +} from './themed-external-login-review-account-info-page.component'; +import { ReviewAccountInfoComponent } from './review-account-info/review-account-info.component'; +import { UiSwitchModule } from 'ngx-ui-switch'; +import { SharedModule } from '../shared/shared.module'; +import { ExternalLoginModule } from '../external-log-in/external-login.module'; + +@NgModule({ + declarations: [ + ExternalLoginReviewAccountInfoPageComponent, + CompareValuesPipe, + ThemedExternalLoginReviewAccountInfoPageComponent, + ReviewAccountInfoComponent + ], + imports: [ + CommonModule, + ExternalLoginReviewAccountInfoRoutingModule, + SharedModule, + UiSwitchModule, + ExternalLoginModule + ] +}) +export class ExternalLoginReviewAccountInfoModule { } 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 0000000000..ff5fb906b5 --- /dev/null +++ b/src/app/external-login-review-account-info-page/helpers/compare-values.pipe.ts @@ -0,0 +1,22 @@ +import { Pipe, PipeTransform } from '@angular/core'; + +@Pipe({ + name: 'dsCompareValues' +}) +export class CompareValuesPipe implements PipeTransform { + + /** + * Returns a string with a checkmark if the received value is equal to the current value, + * or the current value if they are not equal. + * @param receivedValue the value received from the registration data + * @param currentValue the value from the current user + * @returns the value to be displayed in the template + */ + transform(receivedValue: string, currentValue: string): string { + if (receivedValue === currentValue) { + return ''; + } 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 0000000000..4bc0c2b10c --- /dev/null +++ b/src/app/external-login-review-account-info-page/helpers/review-account.guard.spec.ts @@ -0,0 +1,78 @@ +import { TestBed } from '@angular/core/testing'; +import { ReviewAccountGuard } from './review-account.guard'; +import { ActivatedRoute, convertToParamMap, Params, Router } from '@angular/router'; +import { of as observableOf, of } from 'rxjs'; +import { AuthService } from '../../core/auth/auth.service'; +import { EpersonRegistrationService } from '../../core/data/eperson-registration.service'; +import { RouterMock } from '../../shared/mocks/router.mock'; +import { createFailedRemoteDataObject$, createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; +import { Registration } from '../../core/shared/registration.model'; +import { AuthRegistrationType } from '../../core/auth/models/auth.registration-type'; + +describe('ReviewAccountGuard', () => { + let guard: ReviewAccountGuard; + let epersonRegistrationService: any; + let authService: any; + + const route = new RouterMock(); + const registrationMock = Object.assign(new Registration(), + { + email: 'test@email.org', + registrationType: AuthRegistrationType.Validation + + }); + + beforeEach(() => { + const paramObject: Params = {}; + paramObject.token = '1234'; + epersonRegistrationService = jasmine.createSpyObj('epersonRegistrationService', { + searchByTokenAndHandleError: createSuccessfulRemoteDataObject$(registrationMock) + }); + authService = { + isAuthenticated: () => observableOf(true) + } as any; + TestBed.configureTestingModule({ + providers: [{ provide: Router, useValue: route }, + { + provide: ActivatedRoute, + useValue: { + queryParamMap: observableOf(convertToParamMap(paramObject)) + }, + }, + { provide: EpersonRegistrationService, useValue: epersonRegistrationService }, + { provide: AuthService, useValue: authService } + ] + }); + guard = TestBed.inject(ReviewAccountGuard); + }); + + it('should be created', () => { + expect(guard).toBeTruthy(); + }); + + it('can activate must return true when registration type is validation', () => { + (guard.canActivate({ params: { token: 'valid token' } } as any, {} as any) as any) + .subscribe( + (canActivate) => { + expect(canActivate).toEqual(true); + } + ); + }); + + it('should navigate to 404 if the registration search fails', () => { + epersonRegistrationService.searchByTokenAndHandleError.and.returnValue(createFailedRemoteDataObject$()); + (guard.canActivate({ params: { token: 'invalid-token' } } as any, {} as any) as any).subscribe((result) => { + expect(result).toBeFalse(); + expect(route.navigate).toHaveBeenCalledWith(['/404']); + }); + }); + + it('should navigate to 404 if the registration type is not validation and the user is not authenticated', () => { + registrationMock.registrationType = AuthRegistrationType.Orcid; + epersonRegistrationService.searchByTokenAndHandleError.and.returnValue(createSuccessfulRemoteDataObject$(registrationMock)); + spyOn(authService, 'isAuthenticated').and.returnValue(of(false)); + (guard.canActivate({ params: { token: 'invalid-token' } } as any, {} as any) as any).subscribe((result) => { + expect(route.navigate).toHaveBeenCalledWith(['/404']); + }); + }); +}); 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 0000000000..68ca73e265 --- /dev/null +++ b/src/app/external-login-review-account-info-page/helpers/review-account.guard.ts @@ -0,0 +1,65 @@ +import { Injectable } from '@angular/core'; +import { ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot, } from '@angular/router'; +import { catchError, mergeMap, Observable, of, tap } from 'rxjs'; +import { AuthService } from '../../core/auth/auth.service'; +import { AuthRegistrationType } from '../../core/auth/models/auth.registration-type'; +import { EpersonRegistrationService } from '../../core/data/eperson-registration.service'; +import { RemoteData } from '../../core/data/remote-data'; +import { getFirstCompletedRemoteData } from '../../core/shared/operators'; +import { Registration } from '../../core/shared/registration.model'; +import { hasValue } from '../../shared/empty.util'; + +@Injectable({ + providedIn: 'root', +}) +export class ReviewAccountGuard implements CanActivate { + constructor( + private router: Router, + private epersonRegistrationService: EpersonRegistrationService, + private authService: AuthService + ) { } + + /** + * Determines if a user can activate a route based on the registration token. + * @param route - The activated route snapshot. + * @param state - The router state snapshot. + * @returns A value indicating if the user can activate the route. + */ + canActivate( + route: ActivatedRouteSnapshot, + state: RouterStateSnapshot + ): Promise | boolean | Observable { + if (route.params.token) { + return this.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 this.authService.isAuthenticated(); + } + } + return of(false); + } + ), + tap((isValid: boolean) => { + if (!isValid) { + this.router.navigate(['/404']); + } + }), + catchError(() => { + this.router.navigate(['/404']); + return of(false); + }) + ); + } else { + this.router.navigate(['/404']); + return of(false); + } + } +} 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 0000000000..5d6aadc1f8 --- /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 0000000000..1e531f0d8b --- /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 0000000000..63559ef904 --- /dev/null +++ b/src/app/external-login-review-account-info-page/review-account-info/review-account-info.component.spec.ts @@ -0,0 +1,229 @@ +import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing'; + +import { ReviewAccountInfoComponent } from './review-account-info.component'; +import { TranslateLoader, TranslateModule, TranslateService, } from '@ngx-translate/core'; +import { CommonModule } from '@angular/common'; +import { TranslateLoaderMock } from '../../shared/mocks/translate-loader.mock'; +import { EPersonDataService } from '../../core/eperson/eperson-data.service'; +import { Observable, of, Subscription } from 'rxjs'; +import { RemoteData } from '../../core/data/remote-data'; +import { EPerson } from '../../core/eperson/models/eperson.model'; +import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; +import { EPersonMock } from '../../shared/testing/eperson.mock'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub'; +import { Router } from '@angular/router'; +import { RouterMock } from '../../shared/mocks/router.mock'; +import { EventEmitter } from '@angular/core'; +import { CompareValuesPipe } from '../helpers/compare-values.pipe'; +import { Registration } from '../../core/shared/registration.model'; +import { AuthService } from '../../core/auth/auth.service'; +import { AuthServiceMock } from '../../shared/mocks/auth.service.mock'; +import { HardRedirectService } from '../../core/services/hard-redirect.service'; +import { ExternalLoginService } from '../../external-log-in/services/external-login.service'; +import { NativeWindowService } from '../../core/services/window.service'; +import { NativeWindowMockFactory } from '../../shared/mocks/mock-native-window-ref'; + +describe('ReviewAccountInfoComponent', () => { + let component: ReviewAccountInfoComponent; + let componentAsAny: any; + let fixture: ComponentFixture; + 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({ + declarations: [ReviewAccountInfoComponent, CompareValuesPipe], + providers: [ + { provide: NativeWindowService, useFactory: NativeWindowMockFactory }, + { provide: EPersonDataService, useValue: ePersonDataServiceStub }, + { provide: NgbModal, useValue: modalStub }, + { + provide: NotificationsService, + useValue: notificationsService, + }, + { provide: TranslateService, useValue: translateServiceStub }, + { provide: Router, useValue: router }, + { provide: AuthService, useValue: authService }, + { provide: ExternalLoginService, useValue: externalLoginServiceStub }, + { provide: HardRedirectService, useValue: hardRedirectService }, + ], + imports: [ + CommonModule, + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock, + }, + }), + ], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(ReviewAccountInfoComponent); + component = fixture.componentInstance; + componentAsAny = component; + component.registrationData = Object.assign( + new Registration(), + registrationDataMock + ); + component.registrationToken = 'test-token'; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should prepare data to compare', () => { + component.ngOnInit(); + const dataToCompare = component.dataToCompare; + expect(dataToCompare.length).toBe(3); + expect(dataToCompare[0].label).toBe('email'); + expect(dataToCompare[1].label).toBe('lastname'); + expect(dataToCompare[2].label).toBe('firstname'); + expect(dataToCompare[0].overrideValue).toBe(false); + expect(dataToCompare[0].receivedValue).toBe('test@test.com'); + }); + + it('should update dataToCompare when overrideValue is changed', () => { + component.onOverrideChange(true, 'email'); + expect(component.dataToCompare[0].overrideValue).toBe(true); + }); + + it('should open a confirmation modal on onSave and confirm', fakeAsync(() => { + spyOn(modalStub, 'open').and.returnValue({ + componentInstance: { response: of(true) }, + }); + spyOn(component, 'mergeEPersonDataWithToken'); + component.onSave(); + tick(); + expect(modalStub.open).toHaveBeenCalled(); + expect(component.mergeEPersonDataWithToken).toHaveBeenCalled(); + })); + + it('should open a confirmation modal on onSave and cancel', fakeAsync(() => { + spyOn(modalStub, 'open').and.returnValue({ + componentInstance: { response: of(false) }, + }); + spyOn(component, 'mergeEPersonDataWithToken'); + component.onSave(); + tick(); + expect(modalStub.open).toHaveBeenCalled(); + expect(component.mergeEPersonDataWithToken).not.toHaveBeenCalled(); + })); + + it('should merge EPerson data with token when overrideValue is true', fakeAsync(() => { + component.dataToCompare[0].overrideValue = true; + spyOn(ePersonDataServiceStub, 'mergeEPersonDataWithToken').and.returnValue( + of({ hasSucceeded: true }) + ); + component.mergeEPersonDataWithToken(registrationDataMock.user, registrationDataMock.registrationType); + tick(); + expect(ePersonDataServiceStub.mergeEPersonDataWithToken).toHaveBeenCalledTimes(1); + expect(router.navigate).toHaveBeenCalledWith(['/profile']); + })); + + it('should display registration data', () => { + const registrationTypeElement: HTMLElement = fixture.nativeElement.querySelector('tbody tr:first-child th'); + const netIdElement: HTMLElement = fixture.nativeElement.querySelector('tbody tr:first-child td'); + + expect(registrationTypeElement.textContent.trim()).toBe(registrationDataMock.registrationType); + expect(netIdElement.textContent.trim()).toBe(registrationDataMock.netId); + }); + + it('should display dataToCompare rows with translated labels and values', () => { + const dataRows: NodeListOf = 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 0000000000..ab3963d491 --- /dev/null +++ b/src/app/external-login-review-account-info-page/review-account-info/review-account-info.component.ts @@ -0,0 +1,281 @@ +import { ChangeDetectionStrategy, Component, Inject, Input, OnDestroy, OnInit, } from '@angular/core'; +import { EPerson } from '../../core/eperson/models/eperson.model'; +import { EPersonDataService } from '../../core/eperson/eperson-data.service'; +import { combineLatest, filter, from, map, Observable, Subscription, switchMap, take, tap } from 'rxjs'; +import { RemoteData } from '../../core/data/remote-data'; +import { ConfirmationModalComponent } from '../../shared/confirmation-modal/confirmation-modal.component'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { hasValue } from '../../shared/empty.util'; +import { TranslateService } from '@ngx-translate/core'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { Router } from '@angular/router'; +import { Registration } from '../../core/shared/registration.model'; +import { AuthService } from '../../core/auth/auth.service'; +import { HardRedirectService } from '../../core/services/hard-redirect.service'; +import { AuthRegistrationType } from '../../core/auth/models/auth.registration-type'; +import { ExternalLoginService } from '../../external-log-in/services/external-login.service'; +import { NativeWindowRef, NativeWindowService } from '../../core/services/window.service'; +import { AlertType } from '../../shared/alert/alert-type'; + +export interface ReviewAccountInfoData { + label: string; + currentValue: string; + receivedValue: string; + overrideValue: boolean; + identifier: string; +} + +@Component({ + selector: 'ds-review-account-info', + templateUrl: './review-account-info.component.html', + styleUrls: ['./review-account-info.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ReviewAccountInfoComponent implements OnInit, OnDestroy { + /** + * The AlertType enumeration for access in the component's template + * @type {AlertType} + */ + public AlertTypeEnum = AlertType; + /** + * The registration token sent from validation link + */ + @Input() registrationToken: string; + /** + * User data from the registration token + */ + @Input() registrationData: Registration; + + /** + * Text to display when the value is not applicable + */ + notApplicableText = 'N/A'; + /** + * List of data to compare + */ + dataToCompare: ReviewAccountInfoData[] = []; + /** + * List of subscriptions + */ + subs: Subscription[] = []; + + constructor( + @Inject(NativeWindowService) protected _window: NativeWindowRef, + private ePersonService: EPersonDataService, + private modalService: NgbModal, + private notificationService: NotificationsService, + private translateService: TranslateService, + private router: Router, + private authService: AuthService, + private externalLoginService: ExternalLoginService, + private hardRedirectService: HardRedirectService, + ) { } + + ngOnInit(): void { + this.dataToCompare = this.prepareDataToCompare(); + } + + /** + * Find the data to compare based on the metadata key and update the override value + * @param value value of the override checkbox + * @param identifier the metadata key + */ + public onOverrideChange(value: boolean, identifier: string) { + this.dataToCompare.find( + (data) => data.identifier === identifier + ).overrideValue = value; + } + + /** + * Open a confirmation modal to confirm the override of the data + * If confirmed, merge the data from the registration token with the data from the eperson. + * There are 2 cases: + * -> If the user is authenticated, merge the data and redirect to profile page. + * -> If the user is not authenticated, combine the override$, external auth location and redirect URL observables. + */ + public onSave() { + const modalRef = this.modalService.open(ConfirmationModalComponent); + modalRef.componentInstance.headerLabel = + 'confirmation-modal.review-account-info.header'; + modalRef.componentInstance.infoLabel = + 'confirmation-modal.review-account-info.info'; + modalRef.componentInstance.cancelLabel = + 'confirmation-modal.review-account-info.cancel'; + modalRef.componentInstance.confirmLabel = + 'confirmation-modal.review-account-info.confirm'; + modalRef.componentInstance.brandColor = 'primary'; + modalRef.componentInstance.confirmIcon = 'fa fa-check'; + + if (!this.registrationData.user) { + this.subs.push( + this.isAuthenticated() + .pipe( + filter((isAuthenticated) => isAuthenticated), + switchMap(() => this.authService.getAuthenticatedUserFromStore()), + filter((user) => hasValue(user)), + map((user) => user.uuid), + switchMap((userId) => + modalRef.componentInstance.response.pipe( + tap((confirm: boolean) => { + if (confirm) { + this.mergeEPersonDataWithToken(userId, this.registrationData.registrationType); + } + }) + ) + ) + ) + .subscribe() + ); + } else if (this.registrationData.user) { + this.subs.push( + modalRef.componentInstance.response + .pipe(take(1)) + .subscribe((confirm: boolean) => { + if (confirm && this.registrationData.user) { + const registrationType = this.registrationData.registrationType.split(AuthRegistrationType.Validation)[1]; + this.mergeEPersonDataWithToken(this.registrationData.user, registrationType); + } + }) + ); + } + } + + /** + * Merge the data from the registration token with the data from the eperson. + * If any of the metadata is overridden, sent a merge request for each metadata to override. + * If none of the metadata is overridden, sent a merge request with the registration token only. + */ + mergeEPersonDataWithToken(userId: string, registrationType: string) { + let override$: Observable>; + 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 0000000000..53139bb781 --- /dev/null +++ b/src/app/external-login-review-account-info-page/themed-external-login-review-account-info-page.component.ts @@ -0,0 +1,25 @@ +import { Component } from '@angular/core'; +import { ThemedComponent } from '../shared/theme-support/themed.component'; +import { ExternalLoginReviewAccountInfoPageComponent } from './external-login-review-account-info-page.component'; + +/** + * Themed wrapper for ExternalLoginReviewAccountInfoPageComponent + */ +@Component({ + selector: 'ds-themed-external-login-page', + styleUrls: [], + templateUrl: './../shared/theme-support/themed.component.html' +}) +export class ThemedExternalLoginReviewAccountInfoPageComponent extends ThemedComponent { + 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 f3b6b0e404..df190c901a 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 b87f70bf4e..995c1cf905 100644 --- a/src/app/register-email-form/registration.resolver.ts +++ b/src/app/register-email-form/registration.resolver.ts @@ -1,9 +1,5 @@ import { inject } from '@angular/core'; -import { - ActivatedRouteSnapshot, - ResolveFn, - RouterStateSnapshot, -} from '@angular/router'; +import { ActivatedRouteSnapshot, ResolveFn, RouterStateSnapshot, } from '@angular/router'; import { Observable } from 'rxjs'; import { EpersonRegistrationService } from '../core/data/eperson-registration.service'; @@ -17,7 +13,7 @@ export const registrationResolver: ResolveFn> = ( 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 31bc751993..01a7c53c3f 100644 --- a/src/app/register-page/registration.guard.spec.ts +++ b/src/app/register-page/registration.guard.spec.ts @@ -1,18 +1,11 @@ -import { - ActivatedRouteSnapshot, - Router, - RouterStateSnapshot, -} from '@angular/router'; -import { of as observableOf } from 'rxjs'; +import { ActivatedRouteSnapshot, Router, RouterStateSnapshot, } from '@angular/router'; import { AuthService } from '../core/auth/auth.service'; import { EpersonRegistrationService } from '../core/data/eperson-registration.service'; -import { RemoteData } from '../core/data/remote-data'; +import { createFailedRemoteDataObject$, createSuccessfulRemoteDataObject, } from '../shared/remote-data.utils'; import { Registration } from '../core/shared/registration.model'; -import { - createFailedRemoteDataObject$, - createSuccessfulRemoteDataObject, -} from '../shared/remote-data.utils'; +import { of as observableOf } from 'rxjs/internal/observable/of'; +import { RemoteData } from '../core/data/remote-data'; import { registrationGuard } from './registration.guard'; describe('registrationGuard', () => { @@ -53,7 +46,7 @@ describe('registrationGuard', () => { }); epersonRegistrationService = jasmine.createSpyObj('epersonRegistrationService', { - searchByToken: observableOf(registrationRD), + searchByTokenAndUpdateData: observableOf(registrationRD), }); router = jasmine.createSpyObj('router', { navigateByUrl: Promise.resolve(), @@ -71,7 +64,7 @@ describe('registrationGuard', () => { describe('canActivate', () => { describe('when searchByToken returns a successful response', () => { beforeEach(() => { - (epersonRegistrationService.searchByToken as jasmine.Spy).and.returnValue(observableOf(registrationRD)); + (epersonRegistrationService.searchByTokenAndUpdateData as jasmine.Spy).and.returnValue(observableOf(registrationRD)); }); it('should return true', (done) => { @@ -98,7 +91,7 @@ describe('registrationGuard', () => { describe('when searchByToken returns a 404 response', () => { beforeEach(() => { - (epersonRegistrationService.searchByToken as jasmine.Spy).and.returnValue(createFailedRemoteDataObject$('Not Found', 404)); + (epersonRegistrationService.searchByTokenAndUpdateData as jasmine.Spy).and.returnValue(createFailedRemoteDataObject$('Not Found', 404)); }); it('should redirect', () => { 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 0000000000..e69de29bb2 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 0000000000..e69de29bb2 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 0000000000..e69de29bb2 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 0000000000..e69de29bb2 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 0000000000..e69de29bb2 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 0000000000..e69de29bb2 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 bef6f43b66..3b6ea5d054 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 3c7d829a48..2e99bc084b 100644 --- a/src/app/shared/log-in/log-in.component.ts +++ b/src/app/shared/log-in/log-in.component.ts @@ -1,22 +1,7 @@ -import { - AsyncPipe, - NgFor, - NgIf, -} from '@angular/common'; -import { - ChangeDetectionStrategy, - Component, - Input, - OnInit, -} from '@angular/core'; -import { - select, - Store, -} from '@ngrx/store'; -import { - map, - Observable, -} from 'rxjs'; +import { AsyncPipe, NgFor, NgIf, } from '@angular/common'; +import { ChangeDetectionStrategy, Component, Input, OnInit, } from '@angular/core'; +import { select, Store, } from '@ngrx/store'; +import { Observable, } from 'rxjs'; import { AuthService } from '../../core/auth/auth.service'; import { AuthMethod } from '../../core/auth/models/auth.method'; @@ -27,10 +12,13 @@ import { isAuthenticationLoading, } from '../../core/auth/selectors'; import { CoreState } from '../../core/core-state.model'; +import { AuthMethodType } from '../../core/auth/models/auth.method-type'; +import { map } from 'rxjs/operators'; import { hasValue } from '../empty.util'; import { ThemedLoadingComponent } from '../loading/themed-loading.component'; import { LogInContainerComponent } from './container/log-in-container.component'; import { rendersAuthMethodType } from './methods/log-in.methods-decorator'; +import uniqBy from 'lodash/uniqBy'; @Component({ selector: 'ds-base-log-in', @@ -48,6 +36,15 @@ export class LogInComponent implements OnInit { */ @Input() isStandalonePage: boolean; + /** + * Method to exclude from the list of authentication methods + */ + @Input() excludedAuthMethod: AuthMethodType; + /** + * Weather or not to show the register link + */ + @Input() showRegisterLink = true; + /** * The list of authentication methods available * @type {AuthMethod[]} @@ -75,9 +72,13 @@ export class LogInComponent implements OnInit { this.authMethods = this.store.pipe( select(getAuthenticationMethods), map((methods: AuthMethod[]) => methods + // ignore the given auth method if it should be excluded + .filter((authMethod: AuthMethod) => authMethod.authMethodType !== this.excludedAuthMethod) .filter((authMethod: AuthMethod) => rendersAuthMethodType(authMethod.authMethodType) !== undefined) .sort((method1: AuthMethod, method2: AuthMethod) => method1.position - method2.position), ), + // ignore the ip authentication method when it's returned by the backend + map((authMethods: AuthMethod[]) => uniqBy(authMethods.filter(a => a.authMethodType !== AuthMethodType.Ip), 'authMethodType')) ); // set loading 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 104bbd21ce..da5bf04084 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 @@ -1,13 +1,6 @@ import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; -import { - ComponentFixture, - TestBed, - waitForAsync, -} from '@angular/core/testing'; -import { - ActivatedRoute, - Router, -} from '@angular/router'; +import { ComponentFixture, TestBed, waitForAsync, } from '@angular/core/testing'; +import { ActivatedRoute, Router, } from '@angular/router'; import { StoreModule } from '@ngrx/store'; import { provideMockStore } from '@ngrx/store/testing'; import { TranslateModule } from '@ngx-translate/core'; @@ -108,8 +101,7 @@ describe('LogInExternalProviderComponent', () => { component.redirectToExternalProvider(); - expect(setHrefSpy).toHaveBeenCalledWith(currentUrl); - + expect(hardRedirectService.redirect).toHaveBeenCalled(); }); it('should not set a new redirectUrl', () => { 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 13741f412e..40b0f661d9 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 @@ -1,33 +1,17 @@ -import { - Component, - Inject, - OnInit, -} from '@angular/core'; -import { - select, - Store, -} from '@ngrx/store'; +import { Component, Inject, OnInit, } from '@angular/core'; +import { select, Store, } from '@ngrx/store'; import { TranslateModule } from '@ngx-translate/core'; import { Observable } from 'rxjs'; import { take } from 'rxjs/operators'; -import { AuthService } from '../../../../core/auth/auth.service'; import { AuthMethod } from '../../../../core/auth/models/auth.method'; -import { - isAuthenticated, - isAuthenticationLoading, -} from '../../../../core/auth/selectors'; + +import { isAuthenticated, isAuthenticationLoading } from '../../../../core/auth/selectors'; +import { NativeWindowRef, NativeWindowService } from '../../../../core/services/window.service'; +import { isEmpty } from '../../../empty.util'; +import { AuthService } from '../../../../core/auth/auth.service'; import { CoreState } from '../../../../core/core-state.model'; import { HardRedirectService } from '../../../../core/services/hard-redirect.service'; -import { - NativeWindowRef, - NativeWindowService, -} from '../../../../core/services/window.service'; -import { URLCombiner } from '../../../../core/url-combiner/url-combiner'; -import { - isEmpty, - isNotNull, -} from '../../../empty.util'; @Component({ selector: 'ds-log-in-external-provider', @@ -104,24 +88,14 @@ export class LogInExternalProviderComponent implements OnInit { } else if (isEmpty(redirectRoute)) { redirectRoute = '/'; } - const correctRedirectUrl = new URLCombiner(this._window.nativeWindow.origin, redirectRoute).toString(); - - let externalServerUrl = this.location; - const myRegexp = /\?redirectUrl=(.*)/g; - const match = myRegexp.exec(this.location); - const redirectUrlFromServer = (match && match[1]) ? match[1] : null; - - // Check whether the current page is different from the redirect url received from rest - if (isNotNull(redirectUrlFromServer) && redirectUrlFromServer !== correctRedirectUrl) { - // change the redirect url with the current page url - const newRedirectUrl = `?redirectUrl=${correctRedirectUrl}`; - externalServerUrl = this.location.replace(/\?redirectUrl=(.*)/g, newRedirectUrl); - } - - // redirect to shibboleth authentication url + const externalServerUrl = this.authService.getExternalServerRedirectUrl( + this._window.nativeWindow.origin, + redirectRoute, + this.location + ); + // redirect to shibboleth/orcid/(external) authentication url this.hardRedirectService.redirect(externalServerUrl); }); - } getButtonLabel() { diff --git a/src/app/shared/mocks/auth.service.mock.ts b/src/app/shared/mocks/auth.service.mock.ts index c5737ca170..eb55bcfc46 100644 --- a/src/app/shared/mocks/auth.service.mock.ts +++ b/src/app/shared/mocks/auth.service.mock.ts @@ -1,8 +1,5 @@ /* eslint-disable no-empty, @typescript-eslint/no-empty-function */ -import { - Observable, - of as observableOf, -} from 'rxjs'; +import { Observable, of as observableOf, } from 'rxjs'; export class AuthServiceMock { public checksAuthenticationToken() { @@ -29,4 +26,16 @@ export class AuthServiceMock { public isUserIdle(): Observable { 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 5a40e05d1d..e40a96421e 100644 --- a/src/app/shared/testing/auth-service.stub.ts +++ b/src/app/shared/testing/auth-service.stub.ts @@ -1,7 +1,4 @@ -import { - Observable, - of as observableOf, -} from 'rxjs'; +import { Observable, of as observableOf, } from 'rxjs'; import { AuthMethod } from '../../core/auth/models/auth.method'; import { AuthMethodType } from '../../core/auth/models/auth.method-type'; @@ -11,6 +8,7 @@ import { EPerson } from '../../core/eperson/models/eperson.model'; import { hasValue } from '../empty.util'; import { createSuccessfulRemoteDataObject$ } from '../remote-data.utils'; import { EPersonMock } from './eperson.mock'; +import { RetrieveAuthMethodsAction } from '../../core/auth/auth.actions'; export const authMethodsMock: AuthMethod[] = [ new AuthMethod(AuthMethodType.Password, 0), @@ -128,6 +126,7 @@ export class AuthServiceStub { checkAuthenticationCookie() { return; } + setExternalAuthStatus(externalCookie: boolean) { this._isExternalAuth = externalCookie; } @@ -179,4 +178,16 @@ export class AuthServiceStub { clearRedirectUrl() { return; } + + public replaceToken(token: AuthTokenInfo) { + return token; + } + + getRetrieveAuthMethodsAction(authStatus: AuthStatus): RetrieveAuthMethodsAction { + return; + } + + public getExternalServerRedirectUrl(redirectRoute: string, location: string) { + return; + } } diff --git a/src/assets/i18n/en.json5 b/src/assets/i18n/en.json5 index bf866950bc..47c97b8782 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 4a5d3a4881..f68394e7dd 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", @@ -7856,5 +7872,119 @@ // "admin.system-wide-alert.title": "System-wide Alerts", "admin.system-wide-alert.title": "Allarmi di sistema", + // "external-login.confirmation.header": "Information needed to complete the login process", + // TODO New key - Add a translation + "external-login.confirmation.header": "Information needed to complete the login process", + // "external-login.noEmail.informationText": "The information received from {{authMethod}} are not sufficient to complete the login process. Please provide the missing information below, or login via a different method to associate your {{authMethod}} to an existing account.", + // TODO New key - Add a translation + "external-login.noEmail.informationText": "The information received from {{authMethod}} are not sufficient to complete the login process. Please provide the missing information below, or login via a different method to associate your {{authMethod}} to an existing account.", + + // "external-login.haveEmail.informationText": "It seems that you have not yet an account in this system. If this is the case, please confirm the data received from {{authMethod}} and a new account will be created for you. Otherwise, if you already have an account in the system, please update the email address to match the one already in use in the system or login via a different method to associate your {{authMethod}} to your existing account.", + // TODO New key - Add a translation + "external-login.haveEmail.informationText": "It seems that you have not yet an account in this system. If this is the case, please confirm the data received from {{authMethod}} and a new account will be created for you. Otherwise, if you already have an account in the system, please update the email address to match the one already in use in the system or login via a different method to associate your {{authMethod}} to your existing account.", + + // "external-login.confirm-email.header": "Confirm or update email", + // TODO New key - Add a translation + "external-login.confirm-email.header": "Confirm or update email", + + // "external-login.confirmation.email-required": "Email is required.", + // TODO New key - Add a translation + "external-login.confirmation.email-required": "Email is required.", + + // "external-login.confirmation.email-invalid": "Invalid email format.", + // TODO New key - Add a translation + "external-login.confirmation.email-invalid": "Invalid email format.", + + // "external-login.confirm.button.label": "Confirm this email", + // TODO New key - Add a translation + "external-login.confirm.button.label": "Confirm this email", + + // "external-login.confirm-email-sent.header": "Confirmation email sent", + // TODO New key - Add a translation + "external-login.confirm-email-sent.header": "Confirmation email sent", + + // "external-login.confirm-email-sent.info": " We have sent an emait to the provided address to validate your input.
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.", } From 83615a1c90ab8e42b52142480b3a2f330770ca5c Mon Sep 17 00:00:00 2001 From: Alisa Ismailati Date: Tue, 24 Sep 2024 13:00:29 +0200 Subject: [PATCH 02/88] [CST-15073][CST-15074] Adaptation with standalone components --- src/app/app-routes.ts | 24 ++- src/app/core/auth/auth-request.service.ts | 36 ++-- src/app/core/auth/auth.service.ts | 71 +++++--- .../core/auth/models/machine-token.model.ts | 10 +- .../core/data/eperson-registration.service.ts | 42 +++-- .../core/eperson/eperson-data.service.spec.ts | 44 ++++- src/app/core/eperson/eperson-data.service.ts | 56 +++++-- src/app/core/shared/registration.model.ts | 4 +- .../external-log-in.methods-decorator.ts | 4 +- .../external-login-method-entry.component.ts | 12 +- .../confirm-email.component.spec.ts | 44 +++-- .../confirm-email/confirm-email.component.ts | 66 ++++++-- .../confirmation-sent.component.spec.ts | 36 ++-- .../confirmation-sent.component.ts | 11 +- .../provide-email.component.spec.ts | 28 ++-- .../provide-email/provide-email.component.ts | 25 ++- .../external-log-in.component.spec.ts | 45 +++-- .../external-log-in.component.ts | 47 +++++- .../external-log-in/external-login.module.ts | 35 ---- .../guards/registration-token-guard.ts | 53 ++++++ .../guards/registration-token.guard.spec.ts | 158 ++++++++++-------- .../guards/registration-token.guard.ts | 49 ------ .../models/registration-data.mock.model.ts | 4 +- .../orcid-confirmation.component.html | 4 +- .../orcid-confirmation.component.spec.ts | 39 +++-- .../orcid-confirmation.component.ts | 36 +++- .../registration-data.resolver.spec.ts | 18 +- .../resolvers/registration-data.resolver.ts | 15 +- .../services/external-login.service.spec.ts | 60 ++++--- .../services/external-login.service.ts | 20 ++- ...al-login-email-confirmation-page-routes.ts | 1 + ...-email-confirmation-page-routing.module.ts | 17 -- ...-email-confirmation-page.component.spec.ts | 39 +++-- ...login-email-confirmation-page.component.ts | 6 +- ...al-login-email-confirmation-page.module.ts | 21 --- .../external-login-page-routing.module.ts | 22 --- .../external-login-page.component.spec.ts | 30 +++- .../external-login-page.component.ts | 36 +++- .../external-login-page.module.ts | 26 --- .../external-login-routes.ts | 7 +- .../themed-external-login-page.component.ts | 7 +- ...l-login-review-account-info-page-routes.ts | 11 +- ...review-account-info-page-routing.module.ts | 21 --- ...review-account-info-page.component.spec.ts | 34 +++- ...ogin-review-account-info-page.component.ts | 34 +++- ...l-login-review-account-info-page.module.ts | 30 ---- .../helpers/compare-values.pipe.ts | 8 +- .../helpers/review-account.guard.spec.ts | 145 ++++++++++------ .../helpers/review-account.guard.ts | 110 ++++++------ .../review-account-info.component.html | 2 +- .../review-account-info.component.spec.ts | 66 +++++--- .../review-account-info.component.ts | 110 ++++++++---- ...ogin-review-account-info-page.component.ts | 7 +- .../registration.resolver.spec.ts | 2 +- .../registration.resolver.ts | 6 +- .../register-page/registration.guard.spec.ts | 15 +- src/app/register-page/registration.guard.ts | 2 +- src/app/shared/log-in/log-in.component.ts | 28 +++- ...log-in-external-provider.component.spec.ts | 11 +- .../log-in-external-provider.component.ts | 28 +++- .../shared/log-in/themed-log-in.component.ts | 7 +- src/app/shared/mocks/auth.service.mock.ts | 5 +- src/app/shared/testing/auth-service.stub.ts | 7 +- 63 files changed, 1211 insertions(+), 786 deletions(-) delete mode 100644 src/app/external-log-in/external-login.module.ts create mode 100644 src/app/external-log-in/guards/registration-token-guard.ts delete mode 100644 src/app/external-log-in/guards/registration-token.guard.ts delete mode 100644 src/app/external-login-email-confirmation-page/external-login-email-confirmation-page-routing.module.ts delete mode 100644 src/app/external-login-email-confirmation-page/external-login-email-confirmation-page.module.ts delete mode 100644 src/app/external-login-page/external-login-page-routing.module.ts delete mode 100644 src/app/external-login-page/external-login-page.module.ts delete mode 100644 src/app/external-login-review-account-info-page/external-login-review-account-info-page-routing.module.ts delete mode 100644 src/app/external-login-review-account-info-page/external-login-review-account-info-page.module.ts diff --git a/src/app/app-routes.ts b/src/app/app-routes.ts index c298068281..a2667cf2ac 100644 --- a/src/app/app-routes.ts +++ b/src/app/app-routes.ts @@ -1,4 +1,8 @@ -import { InMemoryScrollingOptions, Route, RouterConfigOptions, } from '@angular/router'; +import { + InMemoryScrollingOptions, + Route, + RouterConfigOptions, +} from '@angular/router'; import { NOTIFICATIONS_MODULE_PATH } from './admin/admin-routing-paths'; import { @@ -21,12 +25,8 @@ import { COLLECTION_MODULE_PATH } from './collection-page/collection-page-routin import { COMMUNITY_MODULE_PATH } from './community-page/community-page-routing-paths'; import { authBlockingGuard } from './core/auth/auth-blocking.guard'; import { authenticatedGuard } from './core/auth/authenticated.guard'; -import { - groupAdministratorGuard -} from './core/data/feature-authorization/feature-authorization-guard/group-administrator.guard'; -import { - siteAdministratorGuard -} from './core/data/feature-authorization/feature-authorization-guard/site-administrator.guard'; +import { groupAdministratorGuard } from './core/data/feature-authorization/feature-authorization-guard/group-administrator.guard'; +import { siteAdministratorGuard } from './core/data/feature-authorization/feature-authorization-guard/site-administrator.guard'; import { siteRegisterGuard } from './core/data/feature-authorization/feature-authorization-guard/site-register.guard'; import { endUserAgreementCurrentUserGuard } from './core/end-user-agreement/end-user-agreement-current-user.guard'; import { reloadGuard } from './core/reload/reload.guard'; @@ -37,9 +37,7 @@ import { ITEM_MODULE_PATH } from './item-page/item-page-routing-paths'; import { menuResolver } from './menuResolver'; import { provideSuggestionNotificationsState } from './notifications/provide-suggestion-notifications-state'; import { ThemedPageErrorComponent } from './page-error/themed-page-error.component'; -import { - ThemedPageInternalServerErrorComponent -} from './page-internal-server-error/themed-page-internal-server-error.component'; +import { ThemedPageInternalServerErrorComponent } from './page-internal-server-error/themed-page-internal-server-error.component'; import { ThemedPageNotFoundComponent } from './pagenotfound/themed-pagenotfound.component'; import { PROCESS_MODULE_PATH } from './process-page/process-page-routing.paths'; import { provideSubmissionState } from './submission/provide-submission-state'; @@ -261,17 +259,17 @@ export const APP_ROUTES: Route[] = [ }, { path: 'external-login/:token', - loadChildren: () => import('./external-login-page/external-login-routes').then((m) => m.ROUTES) + 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) + .then((m) => m.ROUTES), }, { path: 'email-confirmation', loadChildren: () => import('./external-login-email-confirmation-page/external-login-email-confirmation-page-routes') - .then((m) => m.ROUTES) + .then((m) => m.ROUTES), }, { path: '**', pathMatch: 'full', component: ThemedPageNotFoundComponent }, ], diff --git a/src/app/core/auth/auth-request.service.ts b/src/app/core/auth/auth-request.service.ts index 5c4843a937..3fb0de9d50 100644 --- a/src/app/core/auth/auth-request.service.ts +++ b/src/app/core/auth/auth-request.service.ts @@ -1,22 +1,36 @@ import { Observable } from 'rxjs'; -import { distinctUntilChanged, filter, map, switchMap, take, tap, } from 'rxjs/operators'; +import { + distinctUntilChanged, + filter, + map, + switchMap, + take, + tap, +} from 'rxjs/operators'; -import { isNotEmpty, isNotEmptyOperator } from '../../shared/empty.util'; +import { + isNotEmpty, + isNotEmptyOperator, +} from '../../shared/empty.util'; import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; -import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { RequestService } from '../data/request.service'; -import { DeleteRequest, GetRequest, PostRequest } from '../data/request.models'; -import { HttpOptions } from '../dspace-rest/dspace-rest.service'; -import { getFirstCompletedRemoteData } from '../shared/operators'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { RemoteData } from '../data/remote-data'; +import { + DeleteRequest, + GetRequest, + PostRequest, +} from '../data/request.models'; +import { RequestService } from '../data/request.service'; import { RestRequest } from '../data/rest-request.model'; +import { HttpOptions } from '../dspace-rest/dspace-rest.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { 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 { ShortLivedToken } from './models/short-lived-token.model'; import { MachineToken } from './models/machine-token.model'; -import { NoContent } from '../shared/NoContent.model'; -import { sendRequest } from '../shared/request.operators'; +import { ShortLivedToken } from './models/short-lived-token.model'; /** * Abstract service to send authentication requests @@ -144,7 +158,7 @@ export abstract class AuthRequestService { 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)) + 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 27ed870a6a..59aabf25ec 100644 --- a/src/app/core/auth/auth.service.ts +++ b/src/app/core/auth/auth.service.ts @@ -1,14 +1,33 @@ import { HttpHeaders } from '@angular/common/http'; -import { Inject, Injectable, Optional, } from '@angular/core'; +import { + Inject, + Injectable, + Optional, +} from '@angular/core'; import { Router } from '@angular/router'; -import { select, Store, } from '@ngrx/store'; +import { + select, + Store, +} from '@ngrx/store'; import { TranslateService } from '@ngx-translate/core'; import { CookieAttributes } from 'js-cookie'; -import { Observable, of as observableOf, } from 'rxjs'; -import { filter, map, startWith, switchMap, take, } from 'rxjs/operators'; +import { + Observable, + of as observableOf, +} from 'rxjs'; +import { + filter, + map, + startWith, + switchMap, + take, +} from 'rxjs/operators'; import { environment } from '../../../environments/environment'; -import { REQUEST, RESPONSE, } from '../../../express.tokens'; +import { + REQUEST, + RESPONSE, +} from '../../../express.tokens'; import { AppState } from '../../app.reducer'; import { hasNoValue, @@ -22,7 +41,10 @@ import { import { NotificationsService } from '../../shared/notifications/notifications.service'; import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; import { followLink } from '../../shared/utils/follow-link-config.model'; -import { buildPaginatedList, PaginatedList, } from '../data/paginated-list.model'; +import { + buildPaginatedList, + PaginatedList, +} from '../data/paginated-list.model'; import { RemoteData } from '../data/remote-data'; import { HttpOptions } from '../dspace-rest/dspace-rest.service'; import { EPersonDataService } from '../eperson/eperson-data.service'; @@ -32,17 +54,16 @@ import { CookieService } from '../services/cookie.service'; import { HardRedirectService } from '../services/hard-redirect.service'; import { RouteService } from '../services/route.service'; import { - getAuthenticatedUserId, - getAuthenticationToken, - getExternalAuthCookieStatus, - getRedirectUrl, - isAuthenticated, - isAuthenticatedLoaded, - isIdle, - isTokenRefreshing -} from './selectors'; -import { getAllSucceededRemoteDataPayload, getFirstCompletedRemoteData, } from '../shared/operators'; + 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, @@ -55,11 +76,21 @@ import { import { AuthRequestService } from './auth-request.service'; import { AuthMethod } from './models/auth.method'; import { AuthStatus } from './models/auth-status.model'; -import { AuthTokenInfo, TOKENITEM, } from './models/auth-token-info.model'; -import { NoContent } from '../shared/NoContent.model'; -import { URLCombiner } from '../url-combiner/url-combiner'; +import { + AuthTokenInfo, + TOKENITEM, +} from './models/auth-token-info.model'; import { MachineToken } from './models/machine-token.model'; -import { NativeWindowRef, NativeWindowService } from '../services/window.service'; +import { + getAuthenticatedUserId, + getAuthenticationToken, + getExternalAuthCookieStatus, + getRedirectUrl, + isAuthenticated, + isAuthenticatedLoaded, + isIdle, + isTokenRefreshing, +} from './selectors'; export const LOGIN_ROUTE = '/login'; export const LOGOUT_ROUTE = '/logout'; diff --git a/src/app/core/auth/models/machine-token.model.ts b/src/app/core/auth/models/machine-token.model.ts index 0b22efb8bf..1d146d743a 100644 --- a/src/app/core/auth/models/machine-token.model.ts +++ b/src/app/core/auth/models/machine-token.model.ts @@ -1,10 +1,14 @@ -import { autoserialize, autoserializeAs, deserialize } from 'cerialize'; +import { + autoserialize, + autoserializeAs, + deserialize, +} from 'cerialize'; import { typedObject } from '../../cache/builders/build-decorators'; import { CacheableObject } from '../../cache/cacheable-object.model'; -import { excludeFromEquals } from '../../utilities/equals.decorators'; -import { ResourceType } from '../../shared/resource-type'; import { HALLink } from '../../shared/hal-link.model'; +import { ResourceType } from '../../shared/resource-type'; +import { excludeFromEquals } from '../../utilities/equals.decorators'; import { MACHINE_TOKEN } from './machine-token.resource-type'; /** diff --git a/src/app/core/data/eperson-registration.service.ts b/src/app/core/data/eperson-registration.service.ts index 524a9bbda3..29c110d80e 100644 --- a/src/app/core/data/eperson-registration.service.ts +++ b/src/app/core/data/eperson-registration.service.ts @@ -1,22 +1,36 @@ -import { HttpHeaders, HttpParams, } from '@angular/common/http'; +import { + HttpHeaders, + HttpParams, +} from '@angular/common/http'; import { Injectable } from '@angular/core'; -import { RequestService } from './request.service'; -import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { GetRequest, PatchRequest, PostRequest } from './request.models'; +import { Operation } from 'fast-json-patch'; import { Observable } from 'rxjs'; -import { filter, find, map, } from 'rxjs/operators'; +import { + filter, + find, + map, +} from 'rxjs/operators'; -import { hasValue, isNotEmpty, } from '../../shared/empty.util'; +import { + hasValue, + isNotEmpty, +} from '../../shared/empty.util'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { HttpOptions } from '../dspace-rest/dspace-rest.service'; import { GenericConstructor } from '../shared/generic-constructor'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { NoContent } from '../shared/NoContent.model'; import { getFirstCompletedRemoteData } from '../shared/operators'; import { Registration } from '../shared/registration.model'; import { ResponseParsingService } from './parsing.service'; import { RegistrationResponseParsingService } from './registration-response-parsing.service'; import { RemoteData } from './remote-data'; -import { Operation } from 'fast-json-patch'; -import { NoContent } from '../shared/NoContent.model'; +import { + GetRequest, + PatchRequest, + PostRequest, +} from './request.models'; +import { RequestService } from './request.service'; @Injectable({ providedIn: 'root', @@ -116,10 +130,10 @@ export class EpersonRegistrationService { map((rd) => { if (rd.hasSucceeded && hasValue(rd.payload)) { return Object.assign(rd, { payload: Object.assign(new Registration(), { - email: rd.payload.email, - token: token, - user: rd.payload.user, - }) }); + email: rd.payload.email, + token: token, + user: rd.payload.user, + }) }); } else { return rd; } @@ -144,7 +158,7 @@ export class EpersonRegistrationService { Object.assign(request, { getResponseParser(): GenericConstructor { return RegistrationResponseParsingService; - } + }, }); this.requestService.send(request, true); }); @@ -186,7 +200,7 @@ export class EpersonRegistrationService { let operations = []; if (values.length > 0 && hasValue(field) ) { operations = [{ - op: operator, path: `/${field}`, value: values + op: operator, path: `/${field}`, value: values, }]; } diff --git a/src/app/core/eperson/eperson-data.service.spec.ts b/src/app/core/eperson/eperson-data.service.spec.ts index 84ab2e2d67..ac735d8e92 100644 --- a/src/app/core/eperson/eperson-data.service.spec.ts +++ b/src/app/core/eperson/eperson-data.service.spec.ts @@ -1,8 +1,19 @@ import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; -import { fakeAsync, TestBed, tick, waitForAsync, } from '@angular/core/testing'; +import { + fakeAsync, + TestBed, + tick, + waitForAsync, +} from '@angular/core/testing'; import { Store } from '@ngrx/store'; -import { MockStore, provideMockStore, } from '@ngrx/store/testing'; -import { compare, Operation, } from 'fast-json-patch'; +import { + MockStore, + provideMockStore, +} from '@ngrx/store/testing'; +import { + compare, + Operation, +} from 'fast-json-patch'; import { cold } from 'jasmine-marbles'; import { of as observableOf } from 'rxjs'; @@ -13,12 +24,21 @@ import { import { getMockRemoteDataBuildServiceHrefMap } from '../../shared/mocks/remote-data-build.service.mock'; import { getMockRequestService } from '../../shared/mocks/request.service.mock'; import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { createNoContentRemoteDataObject$, createSuccessfulRemoteDataObject$, } from '../../shared/remote-data.utils'; -import { EPersonMock, EPersonMock2, } from '../../shared/testing/eperson.mock'; +import { + createNoContentRemoteDataObject$, + createSuccessfulRemoteDataObject$, +} from '../../shared/remote-data.utils'; +import { + EPersonMock, + EPersonMock2, +} from '../../shared/testing/eperson.mock'; import { GroupMock } from '../../shared/testing/group-mock'; import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub'; import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub'; -import { createPaginatedList, createRequestEntry$, } from '../../shared/testing/utils.test'; +import { + createPaginatedList, + createRequestEntry$, +} from '../../shared/testing/utils.test'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { RequestParam } from '../cache/models/request-param.model'; import { ObjectCacheService } from '../cache/object-cache.service'; @@ -26,13 +46,19 @@ import { CoreState } from '../core-state.model'; import { ChangeAnalyzer } from '../data/change-analyzer'; import { DSOChangeAnalyzer } from '../data/dso-change-analyzer.service'; import { FindListOptions } from '../data/find-list-options.model'; -import { PatchRequest, PostRequest, } from '../data/request.models'; +import { RemoteData } from '../data/remote-data'; +import { + PatchRequest, + PostRequest, +} from '../data/request.models'; import { RequestService } from '../data/request.service'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { Item } from '../shared/item.model'; -import { editEPersonSelector, EPersonDataService, } from './eperson-data.service'; +import { + editEPersonSelector, + EPersonDataService, +} from './eperson-data.service'; import { EPerson } from './models/eperson.model'; -import { RemoteData } from '../data/remote-data'; describe('EPersonDataService', () => { let service: EPersonDataService; diff --git a/src/app/core/eperson/eperson-data.service.ts b/src/app/core/eperson/eperson-data.service.ts index 7ffaf0f3ee..33a02de4d7 100644 --- a/src/app/core/eperson/eperson-data.service.ts +++ b/src/app/core/eperson/eperson-data.service.ts @@ -1,8 +1,16 @@ import { Injectable } from '@angular/core'; -import { createSelector, select, Store, } from '@ngrx/store'; +import { + createSelector, + select, + Store, +} from '@ngrx/store'; import { Operation } from 'fast-json-patch'; import { Observable } from 'rxjs'; -import { find, map, take, } from 'rxjs/operators'; +import { + find, + map, + take, +} from 'rxjs/operators'; import { getEPersonEditRoute } from '../../access-control/access-control-routing-paths'; import { @@ -11,27 +19,51 @@ import { } from '../../access-control/epeople-registry/epeople-registry.actions'; import { EPeopleRegistryState } from '../../access-control/epeople-registry/epeople-registry.reducers'; import { AppState } from '../../app.reducer'; -import { hasNoValue, hasValue, } from '../../shared/empty.util'; +import { + hasNoValue, + hasValue, +} from '../../shared/empty.util'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { RequestParam } from '../cache/models/request-param.model'; import { ObjectCacheService } from '../cache/object-cache.service'; -import { CreateData, CreateDataImpl, } from '../data/base/create-data'; -import { DeleteData, DeleteDataImpl, } from '../data/base/delete-data'; +import { + CreateData, + CreateDataImpl, +} from '../data/base/create-data'; +import { + DeleteData, + DeleteDataImpl, +} from '../data/base/delete-data'; import { IdentifiableDataService } from '../data/base/identifiable-data.service'; -import { PatchData, PatchDataImpl, } from '../data/base/patch-data'; -import { SearchData, SearchDataImpl, } from '../data/base/search-data'; +import { + PatchData, + PatchDataImpl, +} from '../data/base/patch-data'; +import { + SearchData, + SearchDataImpl, +} from '../data/base/search-data'; import { DSOChangeAnalyzer } from '../data/dso-change-analyzer.service'; import { FindListOptions } from '../data/find-list-options.model'; -import { buildPaginatedList, PaginatedList, } from '../data/paginated-list.model'; +import { + buildPaginatedList, + PaginatedList, +} from '../data/paginated-list.model'; import { RemoteData } from '../data/remote-data'; -import { PatchRequest, PostRequest, } from '../data/request.models'; +import { + PatchRequest, + PostRequest, +} from '../data/request.models'; import { RequestService } from '../data/request.service'; import { RestRequestMethod } from '../data/rest-request-method'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { NoContent } from '../shared/NoContent.model'; -import { getFirstSucceededRemoteData, getRemoteDataPayload, } from '../shared/operators'; +import { + getFirstSucceededRemoteData, + getRemoteDataPayload, +} from '../shared/operators'; import { PageInfo } from '../shared/page-info.model'; import { EPerson } from './models/eperson.model'; @@ -375,8 +407,8 @@ export class EPersonDataService extends IdentifiableDataService impleme map((href: string) => hasValue(metadataKey) ? `${href}/${uuid}?token=${token}&override=${metadataKey}` - : `${href}/${uuid}?token=${token}` - ) + : `${href}/${uuid}?token=${token}`, + ), ); hrefObs.pipe( diff --git a/src/app/core/shared/registration.model.ts b/src/app/core/shared/registration.model.ts index 8bb02a1251..90663042fc 100644 --- a/src/app/core/shared/registration.model.ts +++ b/src/app/core/shared/registration.model.ts @@ -1,10 +1,10 @@ // 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'; -import { MetadataValue } from './metadata.models'; -import { AuthRegistrationType } from '../auth/models/auth.registration-type'; export class RegistrationDataMetadataMap { [key: string]: RegistrationDataMetadataValue[]; 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 index ce90aea0a3..0144924776 100644 --- 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 @@ -9,7 +9,7 @@ const authMethodsMap = new Map(); * @param authMethodType the type of the external login method */ export function renderExternalLoginConfirmationFor( - authMethodType: AuthRegistrationType + authMethodType: AuthRegistrationType, ) { return function decorator(objectElement: any) { if (!objectElement) { @@ -23,7 +23,7 @@ export function renderExternalLoginConfirmationFor( * @param authMethodType the type of the external login method */ export function getExternalLoginConfirmationType( - authMethodType: AuthRegistrationType + authMethodType: AuthRegistrationType, ) { return authMethodsMap.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 index 47158274b8..d4854cd4bb 100644 --- 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 @@ -1,22 +1,20 @@ -import { Component, Inject } from '@angular/core'; +import { Inject } from '@angular/core'; + import { Registration } from '../../core/shared/registration.model'; /** * This component renders a form to complete the registration process */ -@Component({ - template: '' -}) export abstract class ExternalLoginMethodEntryComponent { /** * The registration data object */ - public registratioData: Registration; + public registrationData: Registration; - constructor( + protected constructor( @Inject('registrationDataProvider') protected injectedRegistrationDataObject: Registration, ) { - this.registratioData = injectedRegistrationDataObject; + this.registrationData = injectedRegistrationDataObject; } } 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 index 9e514472f0..5aa2b91653 100644 --- 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 @@ -1,24 +1,40 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { ConfirmEmailComponent } from './confirm-email.component'; -import { FormBuilder, ReactiveFormsModule } from '@angular/forms'; import { CommonModule } from '@angular/common'; -import { TranslateLoader, TranslateModule, TranslateService, } from '@ngx-translate/core'; -import { EventEmitter, NO_ERRORS_SCHEMA } from '@angular/core'; -import { ExternalLoginService } from '../../services/external-login.service'; +import { + 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 { NativeWindowService } from '../../../core/services/window.service'; -import { MockWindow, NativeWindowMockFactory } from '../../../shared/mocks/mock-native-window-ref'; -import { By } from '@angular/platform-browser'; +import { ExternalLoginService } from '../../services/external-login.service'; +import { ConfirmEmailComponent } from './confirm-email.component'; describe('ConfirmEmailComponent', () => { let component: ConfirmEmailComponent; @@ -52,7 +68,6 @@ describe('ConfirmEmailComponent', () => { redirect: {}, }); await TestBed.configureTestingModule({ - declarations: [ConfirmEmailComponent], providers: [ FormBuilder, { provide: NativeWindowService, useFactory: NativeWindowMockFactory }, @@ -65,13 +80,14 @@ describe('ConfirmEmailComponent', () => { ], imports: [ CommonModule, + ConfirmEmailComponent, TranslateModule.forRoot({ loader: { provide: TranslateLoader, useClass: TranslateLoaderMock, }, }), - ReactiveFormsModule + ReactiveFormsModule, ], schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); @@ -108,7 +124,7 @@ describe('ConfirmEmailComponent', () => { spyOn(component as any, 'postCreateAccountFromToken'); component.submitForm(); expect( - (component as any).postCreateAccountFromToken + (component as any).postCreateAccountFromToken, ).toHaveBeenCalledWith('test-token', component.registrationData); }); @@ -127,7 +143,7 @@ describe('ConfirmEmailComponent', () => { spyOn(component as any, 'patchUpdateRegistration'); component.submitForm(); expect( - (component as any).postCreateAccountFromToken + (component as any).postCreateAccountFromToken, ).not.toHaveBeenCalled(); expect((component as any).patchUpdateRegistration).not.toHaveBeenCalled(); }); 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 index acc59e8f55..5da1c93705 100644 --- 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 @@ -1,24 +1,60 @@ -import { ChangeDetectionStrategy, Component, Inject, Input, OnDestroy, OnInit } from '@angular/core'; -import { FormBuilder, FormGroup, Validators } from '@angular/forms'; -import { ExternalLoginService } from '../../services/external-login.service'; -import { TranslateService } from '@ngx-translate/core'; +import { 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 { + combineLatest, + Subscription, + take, +} from 'rxjs'; + import { AuthService } from '../../../core/auth/auth.service'; import { EPersonDataService } from '../../../core/eperson/eperson-data.service'; import { EPerson } from '../../../core/eperson/models/eperson.model'; import { HardRedirectService } from '../../../core/services/hard-redirect.service'; -import { getFirstCompletedRemoteData, getRemoteDataPayload } from '../../../core/shared/operators'; +import { + 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 { + hasNoValue, + hasValue, +} from '../../../shared/empty.util'; import { NotificationsService } from '../../../shared/notifications/notifications.service'; -import { NativeWindowRef, NativeWindowService } from '../../../core/services/window.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 + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: true, + imports: [ + TranslateModule, + NgIf, + ReactiveFormsModule, + ], }) export class ConfirmEmailComponent implements OnInit, OnDestroy { /** @@ -55,7 +91,7 @@ export class ConfirmEmailComponent implements OnInit, OnDestroy { ngOnInit() { this.emailForm = this.formBuilder.group({ - email: [this.registrationData.email, [Validators.required, Validators.email]] + email: [this.registrationData.email, [Validators.required, Validators.email]], }); } @@ -102,7 +138,7 @@ export class ConfirmEmailComponent implements OnInit, OnDestroy { */ private postCreateAccountFromToken( token: string, - registrationData: Registration + 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) @@ -131,13 +167,13 @@ export class ConfirmEmailComponent implements OnInit, OnDestroy { getFirstCompletedRemoteData(), ), this.externalLoginService.getExternalAuthLocation(this.registrationData.registrationType), - this.authService.getRedirectUrl().pipe(take(1)) + 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') + 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 @@ -145,12 +181,12 @@ export class ConfirmEmailComponent implements OnInit, OnDestroy { const externalServerUrl = this.authService.getExternalServerRedirectUrl( this._window.nativeWindow.origin, redirectRoute, - location + location, ); // redirect to external registration type authentication url this.hardRedirectService.redirect(externalServerUrl); } - }) + }), ); } 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 index da4e5416d5..3e960a1b79 100644 --- 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 @@ -1,11 +1,21 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { ConfirmationSentComponent } from './confirmation-sent.component'; import { CommonModule } from '@angular/common'; -import { CUSTOM_ELEMENTS_SCHEMA, EventEmitter } from '@angular/core'; -import { TranslateLoader, TranslateModule, TranslateService } from '@ngx-translate/core'; +import { + 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; @@ -17,27 +27,27 @@ describe('ConfirmationSentComponent', () => { instant: (key: any) => 'Mocked Translation Text', onLangChange: new EventEmitter(), onTranslationChange: new EventEmitter(), - onDefaultLangChange: new EventEmitter() + onDefaultLangChange: new EventEmitter(), }; beforeEach(async () => { await TestBed.configureTestingModule({ - declarations: [ ConfirmationSentComponent ], providers: [ { provide: TranslateService, useValue: translateServiceStub }, - ], - imports: [ + ], + imports: [ CommonModule, + ConfirmationSentComponent, TranslateModule.forRoot({ loader: { provide: TranslateLoader, - useClass: TranslateLoaderMock - } + useClass: TranslateLoaderMock, + }, }), ], - schemas: [CUSTOM_ELEMENTS_SCHEMA] + schemas: [CUSTOM_ELEMENTS_SCHEMA], }) - .compileComponents(); + .compileComponents(); }); beforeEach(() => { 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 index 2f82991c0d..5d6f2786cf 100644 --- 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 @@ -1,9 +1,16 @@ -import { ChangeDetectionStrategy, Component } from '@angular/core'; +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 + 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.spec.ts b/src/app/external-log-in/email-confirmation/provide-email/provide-email.component.spec.ts index a346bfa930..1e78b5a32a 100644 --- 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 @@ -1,12 +1,18 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { ProvideEmailComponent } from './provide-email.component'; -import { FormBuilder } from '@angular/forms'; import { CommonModule } from '@angular/common'; -import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; -import { ExternalLoginService } from '../../services/external-login.service'; +import { + 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; @@ -17,23 +23,23 @@ describe('ProvideEmailComponent', () => { const externalLoginService = jasmine.createSpyObj('ExternalLoginService', ['patchUpdateRegistration']); await TestBed.configureTestingModule({ - declarations: [ ProvideEmailComponent ], providers: [ FormBuilder, { provide: ExternalLoginService, useValue: externalLoginService }, ], imports: [ CommonModule, + ProvideEmailComponent, TranslateModule.forRoot({ loader: { provide: TranslateLoader, - useClass: TranslateLoaderMock - } + useClass: TranslateLoaderMock, + }, }), ], - schemas: [CUSTOM_ELEMENTS_SCHEMA] + schemas: [CUSTOM_ELEMENTS_SCHEMA], }) - .compileComponents(); + .compileComponents(); }); beforeEach(() => { 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 index 4e3e220ece..0b88a9c573 100644 --- 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 @@ -1,14 +1,33 @@ -import { ChangeDetectionStrategy, Component, Input, OnDestroy } from '@angular/core'; -import { FormBuilder, FormGroup, Validators } from '@angular/forms'; -import { ExternalLoginService } from '../../services/external-login.service'; +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 { /** 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 index 9f29a41dfc..fa4668b524 100644 --- 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 @@ -1,20 +1,28 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { ExternalLogInComponent } from './external-log-in.component'; -import { TranslateModule, TranslateService } from '@ngx-translate/core'; import { CommonModule } from '@angular/common'; import { EventEmitter } from '@angular/core'; -import { By } from '@angular/platform-browser'; -import { of as observableOf } from 'rxjs'; +import { + 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 { AuthService } from '../../core/auth/auth.service'; -import { AuthServiceMock } from '../../shared/mocks/auth.service.mock'; import { Registration } from '../../core/shared/registration.model'; -import { OrcidConfirmationComponent } from '../registration-types/orcid-confirmation/orcid-confirmation.component'; +import { 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; @@ -37,27 +45,32 @@ describe('ExternalLogInComponent', () => { place: -1, }), ], - } + }, }; const translateServiceStub = { get: () => observableOf('Info Text'), instant: (key: any) => 'Info Text', onLangChange: new EventEmitter(), onTranslationChange: new EventEmitter(), - onDefaultLangChange: new EventEmitter() + onDefaultLangChange: new EventEmitter(), }; beforeEach(() => TestBed.configureTestingModule({ - imports: [CommonModule, TranslateModule.forRoot({})], - declarations: [BrowserOnlyPipe, ExternalLogInComponent, OrcidConfirmationComponent], + imports: [CommonModule, TranslateModule.forRoot({}), BrowserOnlyPipe, ExternalLogInComponent, OrcidConfirmationComponent, BrowserAnimationsModule], providers: [ { provide: TranslateService, useValue: translateServiceStub }, { provide: AuthService, useValue: new AuthServiceMock() }, { provide: NgbModal, useValue: modalService }, - FormBuilder - ] - }).compileComponents() + FormBuilder, + ], + }) + .overrideComponent(ExternalLogInComponent, { + remove: { + imports: [ConfirmEmailComponent], + }, + }) + .compileComponents(), ); beforeEach(() => { 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 index cf0a96f54a..a89fbb7564 100644 --- 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 @@ -1,19 +1,54 @@ -import { ChangeDetectionStrategy, Component, Injector, Input, OnDestroy, OnInit, } from '@angular/core'; -import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'; -import { TranslateService } from '@ngx-translate/core'; +import { + 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 { hasValue, isEmpty } from '../../shared/empty.util'; -import { getExternalLoginConfirmationType } from '../decorators/external-log-in.methods-decorator'; +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 { 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 { /** @@ -108,7 +143,7 @@ export class ExternalLogInComponent implements OnInit, OnDestroy { const authMethodUppercase = authMethod.toUpperCase(); return this.translate.instant( 'external-login.haveEmail.informationText', - { authMethod: authMethodUppercase } + { authMethod: authMethodUppercase }, ); } } diff --git a/src/app/external-log-in/external-login.module.ts b/src/app/external-log-in/external-login.module.ts deleted file mode 100644 index b0d75aa730..0000000000 --- a/src/app/external-log-in/external-login.module.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { NgModule } from '@angular/core'; -import { CommonModule } from '@angular/common'; -import { ConfirmEmailComponent } from './email-confirmation/confirm-email/confirm-email.component'; -import { ConfirmationSentComponent } from './email-confirmation/confirmation-sent/confirmation-sent.component'; -import { ProvideEmailComponent } from './email-confirmation/provide-email/provide-email.component'; -import { ExternalLogInComponent } from './external-log-in/external-log-in.component'; -import { OrcidConfirmationComponent } from './registration-types/orcid-confirmation/orcid-confirmation.component'; -import { SharedModule } from '../shared/shared.module'; - -const COMPONENTS = [ - ExternalLogInComponent, - ProvideEmailComponent, - ConfirmEmailComponent, - ConfirmationSentComponent, -]; - -const ENTRY_COMPONENTS = [OrcidConfirmationComponent]; - -@NgModule({ - declarations: [...COMPONENTS, ...ENTRY_COMPONENTS], - imports: [CommonModule, SharedModule], - exports: [...COMPONENTS, ...ENTRY_COMPONENTS], -}) -export class ExternalLoginModule { - /** - * NOTE: this method allows to resolve issue with components that using a custom decorator - * which are not loaded during SSR otherwise - */ - static withEntryComponents() { - return { - ngModule: ExternalLoginModule, - providers: ENTRY_COMPONENTS.map((component) => ({ provide: component })), - }; - } -} 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 0000000000..df49c712c6 --- /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 index 5a0289697f..940d6a8d66 100644 --- a/src/app/external-log-in/guards/registration-token.guard.spec.ts +++ b/src/app/external-log-in/guards/registration-token.guard.spec.ts @@ -1,79 +1,101 @@ -import { TestBed } from '@angular/core/testing'; -import { RegistrationTokenGuard } from './registration-token.guard'; -import { ActivatedRoute, convertToParamMap, Params, Router } from '@angular/router'; -import { of as observableOf } from 'rxjs'; -import { RouterMock } from '../../shared/mocks/router.mock'; -import { Registration } from '../../core/shared/registration.model'; -import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; -import { EPerson } from '../../core/eperson/models/eperson.model'; -import { EpersonRegistrationService } from '../../core/data/eperson-registration.service'; +import { + 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', () => { - let guard: RegistrationTokenGuard; - const route = new RouterMock(); - const registrationWithGroups = Object.assign(new Registration(), - { - email: 'test@email.org', - token: 'test-token', +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 epersonRegistrationService = jasmine.createSpyObj('epersonRegistrationService', { - searchByTokenAndHandleError: createSuccessfulRemoteDataObject$(registrationWithGroups) - }); - const authService = { - getAuthenticatedUserFromStore: () => observableOf(ePerson), - setRedirectUrl: () => { - return true; - } - } as any; - const ePerson = Object.assign(new EPerson(), { - id: 'test-eperson', - uuid: 'test-eperson' - }); - beforeEach(() => { - const paramObject: Params = {}; - paramObject.token = '1234'; - TestBed.configureTestingModule({ - providers: [{provide: Router, useValue: route}, - { - provide: ActivatedRoute, - useValue: { - queryParamMap: observableOf(convertToParamMap(paramObject)) - }, + 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', }, - {provide: EpersonRegistrationService, useValue: epersonRegistrationService}, - {provide: AuthService, useValue: authService} - ] - }); - guard = TestBed.get(RegistrationTokenGuard); - }); + }, + }; - it('should be created', () => { - expect(guard).toBeTruthy(); - }); - describe('based on the response of "searchByToken have', () => { - it('can activate must return true when registration data includes groups', () => { - (guard.canActivate({ params: { token: '123456789' } } as any, {} as any) as any) - .subscribe( - (canActivate) => { - expect(canActivate).toEqual(true); - } - ); + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [{ provide: Router, useValue: route }, + { + provide: ActivatedRoute, + useValue: arouteStub, + }, + { provide: EpersonRegistrationService, useValue: epersonRegistrationService }, + { provide: AuthService, useValue: authService }, + ], + }); }); - it('can activate must return false when registration data includes groups', () => { - const registrationWithDifferentUsedFromLoggedInt = Object.assign(new Registration(), - { + + 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(registrationWithDifferentUsedFromLoggedInt)); - (guard.canActivate({ params: { token: '123456789' } } as any, {} as any) as any) - .subscribe( - (canActivate) => { - expect(canActivate).toEqual(false); - } - ); - }); + 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/guards/registration-token.guard.ts b/src/app/external-log-in/guards/registration-token.guard.ts deleted file mode 100644 index 2135e3a1ae..0000000000 --- a/src/app/external-log-in/guards/registration-token.guard.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { Injectable } from '@angular/core'; -import { ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot, } from '@angular/router'; -import { map, Observable, of } from 'rxjs'; -import { EpersonRegistrationService } from '../../core/data/eperson-registration.service'; -import { getFirstCompletedRemoteData } from '../../core/shared/operators'; -import { Registration } from '../../core/shared/registration.model'; -import { RemoteData } from '../../core/data/remote-data'; -import { hasValue } from '../../shared/empty.util'; - -@Injectable({ - providedIn: 'root', -}) -export class RegistrationTokenGuard implements CanActivate { - constructor( - private router: Router, - private epersonRegistrationService: EpersonRegistrationService - ) { } - - /** - * Determines if a user can activate a route based on the registration token. - * @param route - The activated route snapshot. - * @param state - The router state snapshot. - * @returns A value indicating if the user can activate the route. - */ - canActivate( - route: ActivatedRouteSnapshot, - state: RouterStateSnapshot - ): Observable { - if (route.params.token) { - return this.epersonRegistrationService - .searchByTokenAndHandleError(route.params.token) - .pipe( - getFirstCompletedRemoteData(), - map( - (data: RemoteData) => { - if (data.hasSucceeded && hasValue(data)) { - return true; - } else { - this.router.navigate(['/404']); - } - } - ) - ); - } else { - this.router.navigate(['/404']); - return of(false); - } - } -} 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 index 51f5fc4434..43efe5a0f3 100644 --- a/src/app/external-log-in/models/registration-data.mock.model.ts +++ b/src/app/external-log-in/models/registration-data.mock.model.ts @@ -1,6 +1,6 @@ -import { Registration } from '../../core/shared/registration.model'; 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(), @@ -41,5 +41,5 @@ export const mockRegistrationDataModel: Registration = Object.assign( }, ], }, - } + }, ); 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 index df5bb454dd..c731b9e545 100644 --- 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 @@ -1,6 +1,6 @@