mirror of
https://github.com/DSpace/dspace-angular.git
synced 2025-10-18 15:33:04 +00:00
Merge pull request #3355 from 4Science/task/main/CST-15074
ORCID Login flow for private emails
This commit is contained in:
@@ -263,6 +263,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 },
|
||||
],
|
||||
},
|
||||
|
@@ -65,6 +65,7 @@ import {
|
||||
import { ClientCookieService } from './core/services/client-cookie.service';
|
||||
import { ListableModule } from './core/shared/listable.module';
|
||||
import { XsrfInterceptor } from './core/xsrf/xsrf.interceptor';
|
||||
import { LOGIN_METHOD_FOR_DECORATOR_MAP } from './external-log-in/decorators/external-log-in.methods-decorator';
|
||||
import { RootModule } from './root.module';
|
||||
import { AUTH_METHOD_FOR_DECORATOR_MAP } from './shared/log-in/methods/log-in.methods-decorator';
|
||||
import { METADATA_REPRESENTATION_COMPONENT_DECORATOR_MAP } from './shared/metadata-representation/metadata-representation.decorator';
|
||||
@@ -165,6 +166,7 @@ export const commonAppConfig: ApplicationConfig = {
|
||||
|
||||
/* Use models object so all decorators are actually called */
|
||||
const modelList = models;
|
||||
const loginMethodForDecoratorMap = LOGIN_METHOD_FOR_DECORATOR_MAP;
|
||||
const workflowTasks = WORKFLOW_TASK_OPTION_DECORATOR_MAP;
|
||||
const advancedWorfklowTasks = ADVANCED_WORKFLOW_TASK_OPTION_DECORATOR_MAP;
|
||||
const metadataRepresentations = METADATA_REPRESENTATION_COMPONENT_DECORATOR_MAP;
|
||||
|
133
src/app/core/auth/auth-methods.service.spec.ts
Normal file
133
src/app/core/auth/auth-methods.service.spec.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import {
|
||||
Store,
|
||||
StoreModule,
|
||||
} from '@ngrx/store';
|
||||
import {
|
||||
MockStore,
|
||||
provideMockStore,
|
||||
} from '@ngrx/store/testing';
|
||||
|
||||
import { storeModuleConfig } from '../../app.reducer';
|
||||
import { AuthMethodTypeComponent } from '../../shared/log-in/methods/auth-methods.type';
|
||||
import { authReducer } from './auth.reducer';
|
||||
import { AuthMethodsService } from './auth-methods.service';
|
||||
import { AuthMethod } from './models/auth.method';
|
||||
import { AuthMethodType } from './models/auth.method-type';
|
||||
|
||||
describe('AuthMethodsService', () => {
|
||||
let service: AuthMethodsService;
|
||||
let store: MockStore;
|
||||
let mockAuthMethods: Map<AuthMethodType, AuthMethodTypeComponent>;
|
||||
let mockAuthMethodsArray: AuthMethod[] = [
|
||||
{ id: 'password', authMethodType: AuthMethodType.Password, position: 2 } as AuthMethod,
|
||||
{ id: 'shibboleth', authMethodType: AuthMethodType.Shibboleth, position: 1 } as AuthMethod,
|
||||
{ id: 'oidc', authMethodType: AuthMethodType.Oidc, position: 3 } as AuthMethod,
|
||||
{ id: 'ip', authMethodType: AuthMethodType.Ip, position: 4 } as AuthMethod,
|
||||
];
|
||||
|
||||
const initialState = {
|
||||
core: {
|
||||
auth: {
|
||||
authMethods: mockAuthMethodsArray,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [
|
||||
StoreModule.forRoot(authReducer, storeModuleConfig),
|
||||
],
|
||||
providers: [
|
||||
AuthMethodsService,
|
||||
provideMockStore({ initialState }),
|
||||
],
|
||||
});
|
||||
|
||||
service = TestBed.inject(AuthMethodsService);
|
||||
store = TestBed.inject(Store) as MockStore;
|
||||
|
||||
// Setup mock auth methods map
|
||||
mockAuthMethods = new Map<AuthMethodType, AuthMethodTypeComponent>();
|
||||
mockAuthMethods.set(AuthMethodType.Password, {} as AuthMethodTypeComponent);
|
||||
mockAuthMethods.set(AuthMethodType.Shibboleth, {} as AuthMethodTypeComponent);
|
||||
mockAuthMethods.set(AuthMethodType.Oidc, {} as AuthMethodTypeComponent);
|
||||
mockAuthMethods.set(AuthMethodType.Ip, {} as AuthMethodTypeComponent);
|
||||
|
||||
});
|
||||
|
||||
it('should be created', () => {
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
|
||||
describe('getAuthMethods', () => {
|
||||
it('should return auth methods sorted by position', () => {
|
||||
|
||||
// Expected result after sorting and filtering IP auth
|
||||
const expected = [
|
||||
{ id: 'shibboleth', authMethodType: AuthMethodType.Shibboleth, position: 1 },
|
||||
{ id: 'password', authMethodType: AuthMethodType.Password, position: 2 },
|
||||
{ id: 'oidc', authMethodType: AuthMethodType.Oidc, position: 3 },
|
||||
];
|
||||
|
||||
service.getAuthMethods(mockAuthMethods).subscribe(result => {
|
||||
expect(result.length).toBe(3);
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
it('should exclude specified auth method type', () => {
|
||||
|
||||
// Expected result after excluding Password auth and filtering IP auth
|
||||
const expected = [
|
||||
{ id: 'shibboleth', authMethodType: AuthMethodType.Shibboleth, position: 1 },
|
||||
{ id: 'oidc', authMethodType: AuthMethodType.Oidc, position: 3 },
|
||||
];
|
||||
|
||||
|
||||
service.getAuthMethods(mockAuthMethods, AuthMethodType.Password).subscribe(result => {
|
||||
expect(result.length).toBe(2);
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
it('should always filter out IP authentication method', () => {
|
||||
|
||||
// Add IP auth to the mock methods map
|
||||
mockAuthMethods.set(AuthMethodType.Ip, {} as AuthMethodTypeComponent);
|
||||
|
||||
|
||||
service.getAuthMethods(mockAuthMethods).subscribe(result => {
|
||||
expect(result.length).toBe(3);
|
||||
expect(result.find(method => method.authMethodType === AuthMethodType.Ip)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle empty auth methods array', () => {
|
||||
const authMethods = new Map<AuthMethodType, AuthMethodTypeComponent>();
|
||||
|
||||
|
||||
service.getAuthMethods(authMethods).subscribe(result => {
|
||||
expect(result.length).toBe(0);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle duplicate auth method types and keep only unique ones', () => {
|
||||
// Arrange
|
||||
const duplicateMethodsArray = [
|
||||
...mockAuthMethodsArray,
|
||||
{ id: 'password2', authMethodType: AuthMethodType.Password, position: 5 } as AuthMethod,
|
||||
];
|
||||
|
||||
|
||||
service.getAuthMethods(mockAuthMethods).subscribe(result => {
|
||||
expect(result.length).toBe(3);
|
||||
// Check that we only have one Password auth method
|
||||
const passwordMethods = result.filter(method => method.authMethodType === AuthMethodType.Password);
|
||||
expect(passwordMethods.length).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
51
src/app/core/auth/auth-methods.service.ts
Normal file
51
src/app/core/auth/auth-methods.service.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import {
|
||||
select,
|
||||
Store,
|
||||
} from '@ngrx/store';
|
||||
import uniqBy from 'lodash/uniqBy';
|
||||
import { Observable } from 'rxjs';
|
||||
import { map } from 'rxjs/operators';
|
||||
|
||||
import { AppState } from '../../app.reducer';
|
||||
import { AuthMethodTypeComponent } from '../../shared/log-in/methods/auth-methods.type';
|
||||
import { rendersAuthMethodType } from '../../shared/log-in/methods/log-in.methods-decorator.utils';
|
||||
import { AuthMethod } from './models/auth.method';
|
||||
import { AuthMethodType } from './models/auth.method-type';
|
||||
import { getAuthenticationMethods } from './selectors';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
/**
|
||||
* Service responsible for managing and filtering authentication methods.
|
||||
* Provides methods to retrieve and process authentication methods from the application store.
|
||||
*/
|
||||
export class AuthMethodsService {
|
||||
constructor(protected store: Store<AppState>) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves and processes authentication methods from the store.
|
||||
*
|
||||
* @param authMethods A map of authentication method types to their corresponding components
|
||||
* @param excludedAuthMethod Optional authentication method type to exclude from the results
|
||||
* @returns An Observable of filtered and sorted authentication methods
|
||||
*/
|
||||
public getAuthMethods(
|
||||
authMethods: Map<AuthMethodType, AuthMethodTypeComponent>,
|
||||
excludedAuthMethod?: AuthMethodType,
|
||||
): Observable<AuthMethod[]> {
|
||||
return this.store.pipe(
|
||||
select(getAuthenticationMethods),
|
||||
map((methods: AuthMethod[]) => methods
|
||||
// ignore the given auth method if it should be excluded
|
||||
.filter((authMethod: AuthMethod) => excludedAuthMethod == null || authMethod.authMethodType !== excludedAuthMethod)
|
||||
.filter((authMethod: AuthMethod) => rendersAuthMethodType(authMethods, 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((methods: AuthMethod[]) => uniqBy(methods.filter(a => a.authMethodType !== AuthMethodType.Ip), 'authMethodType')),
|
||||
);
|
||||
}
|
||||
}
|
@@ -139,4 +139,5 @@ export abstract class AuthRequestService {
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
@@ -62,6 +62,7 @@ import {
|
||||
getFirstCompletedRemoteData,
|
||||
} from '../shared/operators';
|
||||
import { PageInfo } from '../shared/page-info.model';
|
||||
import { URLCombiner } from '../url-combiner/url-combiner';
|
||||
import {
|
||||
CheckAuthenticationTokenAction,
|
||||
RefreshTokenAction,
|
||||
@@ -579,6 +580,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
|
||||
*/
|
||||
@@ -663,5 +689,4 @@ export class AuthService {
|
||||
this.store.dispatch(new UnsetUserAsIdleAction());
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
@@ -6,5 +6,5 @@ export enum AuthMethodType {
|
||||
X509 = 'x509',
|
||||
Oidc = 'oidc',
|
||||
Orcid = 'orcid',
|
||||
Saml = 'saml'
|
||||
Saml = 'saml',
|
||||
}
|
||||
|
4
src/app/core/auth/models/auth.registration-type.ts
Normal file
4
src/app/core/auth/models/auth.registration-type.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export enum AuthRegistrationType {
|
||||
Orcid = 'ORCID',
|
||||
Validation = 'VALIDATION_',
|
||||
}
|
@@ -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({
|
||||
|
@@ -3,6 +3,7 @@ import {
|
||||
HttpParams,
|
||||
} from '@angular/common/http';
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Operation } from 'fast-json-patch';
|
||||
import { Observable } from 'rxjs';
|
||||
import {
|
||||
filter,
|
||||
@@ -18,6 +19,7 @@ import { RemoteDataBuildService } from '../cache/builders/remote-data-build.serv
|
||||
import { HttpOptions } from '../dspace-rest/dspace-rest.service';
|
||||
import { GenericConstructor } from '../shared/generic-constructor';
|
||||
import { HALEndpointService } from '../shared/hal-endpoint.service';
|
||||
import { NoContent } from '../shared/NoContent.model';
|
||||
import { getFirstCompletedRemoteData } from '../shared/operators';
|
||||
import { Registration } from '../shared/registration.model';
|
||||
import { ResponseParsingService } from './parsing.service';
|
||||
@@ -25,6 +27,7 @@ import { RegistrationResponseParsingService } from './registration-response-pars
|
||||
import { RemoteData } from './remote-data';
|
||||
import {
|
||||
GetRequest,
|
||||
PatchRequest,
|
||||
PostRequest,
|
||||
} from './request.models';
|
||||
import { RequestService } from './request.service';
|
||||
@@ -45,7 +48,6 @@ export class EpersonRegistrationService {
|
||||
protected rdbService: RemoteDataBuildService,
|
||||
protected halService: HALEndpointService,
|
||||
) {
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -103,10 +105,11 @@ export class EpersonRegistrationService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Search a registration based on the provided token
|
||||
* @param token
|
||||
* Searches for a registration based on the provided token.
|
||||
* @param token The token to search for.
|
||||
* @returns An observable of remote data containing the registration.
|
||||
*/
|
||||
searchByToken(token: string): Observable<RemoteData<Registration>> {
|
||||
searchByTokenAndUpdateData(token: string): Observable<RemoteData<Registration>> {
|
||||
const requestId = this.requestService.generateRequestId();
|
||||
|
||||
const href$ = this.getTokenSearchEndpoint(token).pipe(
|
||||
@@ -126,7 +129,11 @@ export class EpersonRegistrationService {
|
||||
return this.rdbService.buildSingle<Registration>(href$).pipe(
|
||||
map((rd) => {
|
||||
if (rd.hasSucceeded && hasValue(rd.payload)) {
|
||||
return Object.assign(rd, { payload: Object.assign(rd.payload, { token }) });
|
||||
return Object.assign(rd, { payload: Object.assign(new Registration(), {
|
||||
email: rd.payload.email,
|
||||
token: token,
|
||||
user: rd.payload.user,
|
||||
}) });
|
||||
} else {
|
||||
return rd;
|
||||
}
|
||||
@@ -134,4 +141,69 @@ export class EpersonRegistrationService {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Searches for a registration by token and handles any errors that may occur.
|
||||
* @param token The token to search for.
|
||||
* @returns An observable of remote data containing the registration.
|
||||
*/
|
||||
searchByTokenAndHandleError(token: string): Observable<RemoteData<Registration>> {
|
||||
const requestId = this.requestService.generateRequestId();
|
||||
|
||||
const href$ = this.getTokenSearchEndpoint(token).pipe(
|
||||
find((href: string) => hasValue(href)),
|
||||
);
|
||||
|
||||
href$.subscribe((href: string) => {
|
||||
const request = new GetRequest(requestId, href);
|
||||
Object.assign(request, {
|
||||
getResponseParser(): GenericConstructor<ResponseParsingService> {
|
||||
return RegistrationResponseParsingService;
|
||||
},
|
||||
});
|
||||
this.requestService.send(request, true);
|
||||
});
|
||||
return this.rdbService.buildSingle<Registration>(href$);
|
||||
}
|
||||
|
||||
/**
|
||||
* Patch the registration object to update the email address
|
||||
* @param value provided by the user during the registration confirmation process
|
||||
* @param registrationId The id of the registration object
|
||||
* @param token The token of the registration object
|
||||
* @param updateValue Flag to indicate if the email should be updated or added
|
||||
* @returns Remote Data state of the patch request
|
||||
*/
|
||||
patchUpdateRegistration(values: string[], field: string, registrationId: string, token: string, operator: 'add' | 'replace'): Observable<RemoteData<NoContent>> {
|
||||
const requestId = this.requestService.generateRequestId();
|
||||
|
||||
const href$ = this.getRegistrationEndpoint().pipe(
|
||||
find((href: string) => hasValue(href)),
|
||||
map((href: string) => `${href}/${registrationId}?token=${token}`),
|
||||
);
|
||||
|
||||
href$.subscribe((href: string) => {
|
||||
const operations = this.generateOperations(values, field, operator);
|
||||
const patchRequest = new PatchRequest(requestId, href, operations);
|
||||
this.requestService.send(patchRequest);
|
||||
});
|
||||
|
||||
return this.rdbService.buildFromRequestUUID(requestId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom method to generate the operations to be performed on the registration object
|
||||
* @param value provided by the user during the registration confirmation process
|
||||
* @param updateValue Flag to indicate if the email should be updated or added
|
||||
* @returns Operations to be performed on the registration object
|
||||
*/
|
||||
private generateOperations(values: string[], field: string, operator: 'add' | 'replace'): Operation[] {
|
||||
let operations = [];
|
||||
if (values.length > 0 && hasValue(field) ) {
|
||||
operations = [{
|
||||
op: operator, path: `/${field}`, value: values,
|
||||
}];
|
||||
}
|
||||
|
||||
return operations;
|
||||
}
|
||||
}
|
||||
|
@@ -46,6 +46,7 @@ import { CoreState } from '../core-state.model';
|
||||
import { ChangeAnalyzer } from '../data/change-analyzer';
|
||||
import { DSOChangeAnalyzer } from '../data/dso-change-analyzer.service';
|
||||
import { FindListOptions } from '../data/find-list-options.model';
|
||||
import { RemoteData } from '../data/remote-data';
|
||||
import {
|
||||
PatchRequest,
|
||||
PostRequest,
|
||||
@@ -351,6 +352,21 @@ describe('EPersonDataService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('mergeEPersonDataWithToken', () => {
|
||||
const uuid = '1234-5678-9012-3456';
|
||||
const token = 'abcd-efgh-ijkl-mnop';
|
||||
const metadataKey = 'eperson.firstname';
|
||||
beforeEach(() => {
|
||||
spyOn(service, 'mergeEPersonDataWithToken').and.returnValue(createSuccessfulRemoteDataObject$(EPersonMock));
|
||||
});
|
||||
|
||||
it('should merge EPerson data with token', () => {
|
||||
service.mergeEPersonDataWithToken(uuid, token, metadataKey).subscribe((result: RemoteData<EPerson>) => {
|
||||
expect(result.hasSucceeded).toBeTrue();
|
||||
});
|
||||
expect(service.mergeEPersonDataWithToken).toHaveBeenCalledWith(uuid, token, metadataKey);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
class DummyChangeAnalyzer implements ChangeAnalyzer<Item> {
|
||||
|
@@ -394,6 +394,32 @@ export class EPersonDataService extends IdentifiableDataService<EPerson> impleme
|
||||
return this.rdbService.buildFromRequestUUID(requestId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a POST request to merge registration data related to the provided registration-token,
|
||||
* into the eperson related to the provided uuid
|
||||
* @param uuid the user uuid
|
||||
* @param token registration-token
|
||||
* @param metadataKey metadata key of the metadata field that should be overriden
|
||||
*/
|
||||
mergeEPersonDataWithToken(uuid: string, token: string, metadataKey?: string): Observable<RemoteData<EPerson>> {
|
||||
const requestId = this.requestService.generateRequestId();
|
||||
const hrefObs = this.getBrowseEndpoint().pipe(
|
||||
map((href: string) =>
|
||||
hasValue(metadataKey)
|
||||
? `${href}/${uuid}?token=${token}&override=${metadataKey}`
|
||||
: `${href}/${uuid}?token=${token}`,
|
||||
),
|
||||
);
|
||||
|
||||
hrefObs.pipe(
|
||||
find((href: string) => hasValue(href)),
|
||||
).subscribe((href: string) => {
|
||||
const request = new PostRequest(requestId, href);
|
||||
this.requestService.send(request);
|
||||
});
|
||||
|
||||
return this.rdbService.buildFromRequestUUID(requestId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new object on the server, and store the response in the object cache
|
||||
|
@@ -1,11 +1,27 @@
|
||||
// eslint-disable-next-line max-classes-per-file
|
||||
import { AuthRegistrationType } from '../auth/models/auth.registration-type';
|
||||
import { typedObject } from '../cache/builders/build-decorators';
|
||||
import { MetadataValue } from './metadata.models';
|
||||
import { REGISTRATION } from './registration.resource-type';
|
||||
import { ResourceType } from './resource-type';
|
||||
import { UnCacheableObject } from './uncacheable-object.model';
|
||||
|
||||
export class RegistrationDataMetadataMap {
|
||||
[key: string]: RegistrationDataMetadataValue[];
|
||||
}
|
||||
|
||||
export class RegistrationDataMetadataValue extends MetadataValue {
|
||||
overrides?: string;
|
||||
}
|
||||
@typedObject
|
||||
export class Registration implements UnCacheableObject {
|
||||
static type = REGISTRATION;
|
||||
|
||||
/**
|
||||
* The unique identifier of this registration data
|
||||
*/
|
||||
id: string;
|
||||
|
||||
/**
|
||||
* The object type
|
||||
*/
|
||||
@@ -29,8 +45,24 @@ export class Registration implements UnCacheableObject {
|
||||
* The token linked to the registration
|
||||
*/
|
||||
groupNames: string[];
|
||||
|
||||
/**
|
||||
* The token linked to the registration
|
||||
*/
|
||||
groups: string[];
|
||||
|
||||
/**
|
||||
* The registration type (e.g. orcid, shibboleth, etc.)
|
||||
*/
|
||||
registrationType?: AuthRegistrationType;
|
||||
|
||||
/**
|
||||
* The netId of the user (e.g. for ORCID - <:orcid>)
|
||||
*/
|
||||
netId?: string;
|
||||
|
||||
/**
|
||||
* The metadata involved during the registration process
|
||||
*/
|
||||
registrationMetadata?: RegistrationDataMetadataMap;
|
||||
}
|
||||
|
@@ -0,0 +1,34 @@
|
||||
import { AuthRegistrationType } from 'src/app/core/auth/models/auth.registration-type';
|
||||
|
||||
import { OrcidConfirmationComponent } from '../registration-types/orcid-confirmation/orcid-confirmation.component';
|
||||
|
||||
export type ExternalLoginTypeComponent =
|
||||
typeof OrcidConfirmationComponent;
|
||||
|
||||
export const LOGIN_METHOD_FOR_DECORATOR_MAP = new Map<AuthRegistrationType, ExternalLoginTypeComponent>([
|
||||
[AuthRegistrationType.Orcid, OrcidConfirmationComponent],
|
||||
]);
|
||||
|
||||
/**
|
||||
* Decorator to register the external login confirmation component for the given auth method type
|
||||
* @param authMethodType the type of the external login method
|
||||
*/
|
||||
export function renderExternalLoginConfirmationFor(
|
||||
authMethodType: AuthRegistrationType,
|
||||
) {
|
||||
return function decorator(objectElement: any) {
|
||||
if (!objectElement) {
|
||||
return;
|
||||
}
|
||||
LOGIN_METHOD_FOR_DECORATOR_MAP.set(authMethodType, objectElement);
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Get the external login confirmation component for the given auth method type
|
||||
* @param authMethodType the type of the external login method
|
||||
*/
|
||||
export function getExternalLoginConfirmationType(
|
||||
authMethodType: AuthRegistrationType,
|
||||
) {
|
||||
return LOGIN_METHOD_FOR_DECORATOR_MAP.get(authMethodType);
|
||||
}
|
@@ -0,0 +1,20 @@
|
||||
import { Inject } from '@angular/core';
|
||||
|
||||
import { Registration } from '../../core/shared/registration.model';
|
||||
|
||||
/**
|
||||
* This component renders a form to complete the registration process
|
||||
*/
|
||||
export abstract class ExternalLoginMethodEntryComponent {
|
||||
|
||||
/**
|
||||
* The registration data object
|
||||
*/
|
||||
public registrationData: Registration;
|
||||
|
||||
protected constructor(
|
||||
@Inject('registrationDataProvider') protected injectedRegistrationDataObject: Registration,
|
||||
) {
|
||||
this.registrationData = injectedRegistrationDataObject;
|
||||
}
|
||||
}
|
@@ -0,0 +1,37 @@
|
||||
<h4>
|
||||
{{ "external-login.confirm-email.header" | translate }}
|
||||
</h4>
|
||||
|
||||
<form [formGroup]="emailForm" (ngSubmit)="submitForm()">
|
||||
<div class="form-group">
|
||||
<div class="form-row">
|
||||
<div class="col-12 my-2">
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
formControlName="email"
|
||||
placeholder="profile.email@example.com"
|
||||
class="form-control form-control-lg position-relative"
|
||||
[attr.aria-label]="'external-login.confirmation.email-label' | translate"
|
||||
/>
|
||||
@if (emailForm.get('email').hasError('required') && emailForm.get('email').touched) {
|
||||
<div class="text-danger">
|
||||
{{ "external-login.confirmation.email-required" | translate }}
|
||||
</div>
|
||||
}
|
||||
@if (emailForm.get('email').hasError('email') && emailForm.get('email').touched) {
|
||||
<div class="text-danger">
|
||||
{{ "external-login.confirmation.email-invalid" | translate }}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="col-12">
|
||||
<button type="submit" class="btn btn-lg btn-primary w-100">
|
||||
{{ "external-login.confirm.button.label" | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
@@ -0,0 +1,177 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
EventEmitter,
|
||||
NO_ERRORS_SCHEMA,
|
||||
} from '@angular/core';
|
||||
import {
|
||||
ComponentFixture,
|
||||
TestBed,
|
||||
} from '@angular/core/testing';
|
||||
import {
|
||||
FormBuilder,
|
||||
ReactiveFormsModule,
|
||||
} from '@angular/forms';
|
||||
import { By } from '@angular/platform-browser';
|
||||
import {
|
||||
TranslateLoader,
|
||||
TranslateModule,
|
||||
TranslateService,
|
||||
} from '@ngx-translate/core';
|
||||
import { of } from 'rxjs';
|
||||
|
||||
import { AuthService } from '../../../core/auth/auth.service';
|
||||
import { AuthMethodType } from '../../../core/auth/models/auth.method-type';
|
||||
import { EPersonDataService } from '../../../core/eperson/eperson-data.service';
|
||||
import { EPerson } from '../../../core/eperson/models/eperson.model';
|
||||
import { HardRedirectService } from '../../../core/services/hard-redirect.service';
|
||||
import { NativeWindowService } from '../../../core/services/window.service';
|
||||
import { Registration } from '../../../core/shared/registration.model';
|
||||
import {
|
||||
MockWindow,
|
||||
NativeWindowMockFactory,
|
||||
} from '../../../shared/mocks/mock-native-window-ref';
|
||||
import { TranslateLoaderMock } from '../../../shared/mocks/translate-loader.mock';
|
||||
import { NotificationsService } from '../../../shared/notifications/notifications.service';
|
||||
import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils';
|
||||
import { ExternalLoginService } from '../../services/external-login.service';
|
||||
import { ConfirmEmailComponent } from './confirm-email.component';
|
||||
|
||||
describe('ConfirmEmailComponent', () => {
|
||||
let component: ConfirmEmailComponent;
|
||||
let fixture: ComponentFixture<ConfirmEmailComponent>;
|
||||
let externalLoginServiceSpy: jasmine.SpyObj<ExternalLoginService>;
|
||||
let epersonDataServiceSpy: jasmine.SpyObj<EPersonDataService>;
|
||||
let notificationServiceSpy: jasmine.SpyObj<NotificationsService>;
|
||||
let authServiceSpy: jasmine.SpyObj<AuthService>;
|
||||
let hardRedirectService: HardRedirectService;
|
||||
|
||||
const translateServiceStub = {
|
||||
get: () => of(''),
|
||||
onLangChange: new EventEmitter(),
|
||||
onTranslationChange: new EventEmitter(),
|
||||
onDefaultLangChange: new EventEmitter(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
externalLoginServiceSpy = jasmine.createSpyObj('ExternalLoginService', [
|
||||
'patchUpdateRegistration',
|
||||
'getExternalAuthLocation',
|
||||
]);
|
||||
epersonDataServiceSpy = jasmine.createSpyObj('EPersonDataService', [
|
||||
'createEPersonForToken',
|
||||
]);
|
||||
notificationServiceSpy = jasmine.createSpyObj('NotificationsService', [
|
||||
'error',
|
||||
]);
|
||||
authServiceSpy = jasmine.createSpyObj('AuthService', ['getRedirectUrl', 'setRedirectUrl', 'getExternalServerRedirectUrl']);
|
||||
hardRedirectService = jasmine.createSpyObj('HardRedirectService', {
|
||||
redirect: {},
|
||||
});
|
||||
await TestBed.configureTestingModule({
|
||||
providers: [
|
||||
FormBuilder,
|
||||
{ provide: NativeWindowService, useFactory: NativeWindowMockFactory },
|
||||
{ provide: ExternalLoginService, useValue: externalLoginServiceSpy },
|
||||
{ provide: EPersonDataService, useValue: epersonDataServiceSpy },
|
||||
{ provide: NotificationsService, useValue: notificationServiceSpy },
|
||||
{ provide: AuthService, useValue: authServiceSpy },
|
||||
{ provide: TranslateService, useValue: translateServiceStub },
|
||||
{ provide: HardRedirectService, useValue: hardRedirectService },
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
ConfirmEmailComponent,
|
||||
TranslateModule.forRoot({
|
||||
loader: {
|
||||
provide: TranslateLoader,
|
||||
useClass: TranslateLoaderMock,
|
||||
},
|
||||
}),
|
||||
ReactiveFormsModule,
|
||||
],
|
||||
schemas: [NO_ERRORS_SCHEMA],
|
||||
}).compileComponents();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(ConfirmEmailComponent);
|
||||
component = fixture.componentInstance;
|
||||
component.registrationData = Object.assign(new Registration(), {
|
||||
id: '123',
|
||||
email: 'test@example.com',
|
||||
netId: 'test-netid',
|
||||
registrationMetadata: {},
|
||||
registrationType: AuthMethodType.Orcid,
|
||||
});
|
||||
component.token = 'test-token';
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should show email from registration data', () => {
|
||||
fixture.detectChanges();
|
||||
const emailInput = fixture.debugElement.query(By.css('input[id=email]'));
|
||||
expect(emailInput).toBeTruthy();
|
||||
expect(emailInput.nativeElement.value).toBe('test@example.com');
|
||||
});
|
||||
|
||||
describe('submitForm', () => {
|
||||
it('should call postCreateAccountFromToken if email is confirmed', () => {
|
||||
component.emailForm.setValue({ email: 'test@example.com' });
|
||||
spyOn(component as any, 'postCreateAccountFromToken');
|
||||
component.submitForm();
|
||||
expect(
|
||||
(component as any).postCreateAccountFromToken,
|
||||
).toHaveBeenCalledWith('test-token', component.registrationData);
|
||||
});
|
||||
|
||||
it('should call patchUpdateRegistration if email is not confirmed', () => {
|
||||
component.emailForm.setValue({ email: 'new-email@example.com' });
|
||||
spyOn(component as any, 'patchUpdateRegistration');
|
||||
component.submitForm();
|
||||
expect((component as any).patchUpdateRegistration).toHaveBeenCalledWith([
|
||||
'new-email@example.com',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should not call any methods if form is invalid', () => {
|
||||
component.emailForm.setValue({ email: 'invalid-email' });
|
||||
spyOn(component as any, 'postCreateAccountFromToken');
|
||||
spyOn(component as any, 'patchUpdateRegistration');
|
||||
component.submitForm();
|
||||
expect(
|
||||
(component as any).postCreateAccountFromToken,
|
||||
).not.toHaveBeenCalled();
|
||||
expect((component as any).patchUpdateRegistration).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('postCreateAccountFromToken', () => {
|
||||
it('should call NotificationsService.error if the registration data does not have a netId', () => {
|
||||
component.registrationData.netId = undefined;
|
||||
(component as any).postCreateAccountFromToken('test-token', component.registrationData);
|
||||
expect(notificationServiceSpy.error).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should call EPersonDataService.createEPersonForToken and ExternalLoginService.getExternalAuthLocation if the registration data has a netId', () => {
|
||||
externalLoginServiceSpy.getExternalAuthLocation.and.returnValue(of('test-location'));
|
||||
authServiceSpy.getRedirectUrl.and.returnValue(of('/test-redirect'));
|
||||
authServiceSpy.getExternalServerRedirectUrl.and.returnValue('test-external-url');
|
||||
epersonDataServiceSpy.createEPersonForToken.and.returnValue(createSuccessfulRemoteDataObject$(new EPerson()));
|
||||
(component as any).postCreateAccountFromToken('test-token', component.registrationData);
|
||||
expect(epersonDataServiceSpy.createEPersonForToken).toHaveBeenCalled();
|
||||
expect(externalLoginServiceSpy.getExternalAuthLocation).toHaveBeenCalledWith(AuthMethodType.Orcid);
|
||||
expect(authServiceSpy.getRedirectUrl).toHaveBeenCalled();
|
||||
expect(authServiceSpy.setRedirectUrl).toHaveBeenCalledWith('/profile');
|
||||
expect(authServiceSpy.getExternalServerRedirectUrl).toHaveBeenCalledWith(MockWindow.origin,'/test-redirect', 'test-location');
|
||||
expect(hardRedirectService.redirect).toHaveBeenCalledWith('test-external-url');
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fixture.destroy();
|
||||
});
|
||||
});
|
@@ -0,0 +1,197 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
Inject,
|
||||
Input,
|
||||
OnDestroy,
|
||||
OnInit,
|
||||
} from '@angular/core';
|
||||
import {
|
||||
FormBuilder,
|
||||
FormGroup,
|
||||
ReactiveFormsModule,
|
||||
Validators,
|
||||
} from '@angular/forms';
|
||||
import {
|
||||
TranslateModule,
|
||||
TranslateService,
|
||||
} from '@ngx-translate/core';
|
||||
import isEqual from 'lodash/isEqual';
|
||||
import {
|
||||
combineLatest,
|
||||
Subscription,
|
||||
take,
|
||||
} from 'rxjs';
|
||||
|
||||
import { AuthService } from '../../../core/auth/auth.service';
|
||||
import { EPersonDataService } from '../../../core/eperson/eperson-data.service';
|
||||
import { EPerson } from '../../../core/eperson/models/eperson.model';
|
||||
import { HardRedirectService } from '../../../core/services/hard-redirect.service';
|
||||
import {
|
||||
NativeWindowRef,
|
||||
NativeWindowService,
|
||||
} from '../../../core/services/window.service';
|
||||
import {
|
||||
getFirstCompletedRemoteData,
|
||||
getRemoteDataPayload,
|
||||
} from '../../../core/shared/operators';
|
||||
import { Registration } from '../../../core/shared/registration.model';
|
||||
import {
|
||||
hasNoValue,
|
||||
hasValue,
|
||||
} from '../../../shared/empty.util';
|
||||
import { NotificationsService } from '../../../shared/notifications/notifications.service';
|
||||
import { ExternalLoginService } from '../../services/external-login.service';
|
||||
|
||||
@Component({
|
||||
selector: 'ds-confirm-email',
|
||||
templateUrl: './confirm-email.component.html',
|
||||
styleUrls: ['./confirm-email.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: true,
|
||||
imports: [
|
||||
TranslateModule,
|
||||
ReactiveFormsModule,
|
||||
],
|
||||
})
|
||||
/**
|
||||
* Email confirmation component that will check for user email confirmation after account creation.
|
||||
*/
|
||||
export class ConfirmEmailComponent implements OnInit, OnDestroy {
|
||||
/**
|
||||
* The form containing the email input
|
||||
*/
|
||||
emailForm: FormGroup;
|
||||
/**
|
||||
* The registration data object
|
||||
*/
|
||||
@Input() registrationData: Registration;
|
||||
|
||||
/**
|
||||
* The token to be used to confirm the registration
|
||||
*/
|
||||
@Input() token: string;
|
||||
/**
|
||||
* The subscriptions to unsubscribe from
|
||||
*/
|
||||
subs: Subscription[] = [];
|
||||
|
||||
externalLocation: string;
|
||||
|
||||
constructor(
|
||||
@Inject(NativeWindowService) protected _window: NativeWindowRef,
|
||||
private formBuilder: FormBuilder,
|
||||
private externalLoginService: ExternalLoginService,
|
||||
private epersonDataService: EPersonDataService,
|
||||
private notificationService: NotificationsService,
|
||||
private translate: TranslateService,
|
||||
private authService: AuthService,
|
||||
private hardRedirectService: HardRedirectService,
|
||||
) {
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.emailForm = this.formBuilder.group({
|
||||
email: [this.registrationData.email, [Validators.required, Validators.email]],
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Submits the email form and performs appropriate actions based on the form's validity and user input.
|
||||
* If the form is valid and the confirmed email matches the registration email, calls the postCreateAccountFromToken method with the token and registration data.
|
||||
* If the form is valid but the confirmed email does not match the registration email, calls the patchUpdateRegistration method with the confirmed email.
|
||||
*/
|
||||
submitForm() {
|
||||
this.emailForm.markAllAsTouched();
|
||||
if (this.emailForm.valid) {
|
||||
const confirmedEmail = this.emailForm.get('email').value;
|
||||
if (confirmedEmail && isEqual(this.registrationData.email, confirmedEmail.trim())) {
|
||||
this.postCreateAccountFromToken(this.token, this.registrationData);
|
||||
} else {
|
||||
this.patchUpdateRegistration([confirmedEmail]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a PATCH request to update the user's registration with the given values.
|
||||
* @param values - The values to update the user's registration with.
|
||||
* @returns An Observable that emits the updated registration data.
|
||||
*/
|
||||
private patchUpdateRegistration(values: string[]) {
|
||||
this.subs.push(
|
||||
this.externalLoginService.patchUpdateRegistration(values, 'email', this.registrationData.id, this.token, 'replace')
|
||||
.pipe(getRemoteDataPayload())
|
||||
.subscribe());
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new user from a given token and registration data.
|
||||
* Based on the registration data, the user will be created with the following properties:
|
||||
* - email: the email address from the registration data
|
||||
* - metadata: all metadata values from the registration data, except for the email metadata key (ePerson object does not have an email metadata field)
|
||||
* - canLogIn: true
|
||||
* - requireCertificate: false
|
||||
* @param token The token used to create the user.
|
||||
* @param registrationData The registration data used to create the user.
|
||||
* @returns An Observable that emits a boolean indicating whether the user creation was successful.
|
||||
*/
|
||||
private postCreateAccountFromToken(
|
||||
token: string,
|
||||
registrationData: Registration,
|
||||
) {
|
||||
// check if the netId is present
|
||||
// in order to create an account, the netId is required (since the user is created without a password)
|
||||
if (hasNoValue(this.registrationData.netId)) {
|
||||
this.notificationService.error(this.translate.get('external-login-page.confirm-email.create-account.notifications.error.no-netId'));
|
||||
return;
|
||||
}
|
||||
|
||||
const metadataValues = {};
|
||||
for (const [key, value] of Object.entries(registrationData.registrationMetadata)) {
|
||||
// exclude the email metadata key, since the ePerson object does not have an email metadata field
|
||||
if (hasValue(value[0]?.value) && key !== 'email') {
|
||||
metadataValues[key] = value;
|
||||
}
|
||||
}
|
||||
const eperson = new EPerson();
|
||||
eperson.email = registrationData.email;
|
||||
eperson.netid = registrationData.netId;
|
||||
eperson.metadata = metadataValues;
|
||||
eperson.canLogIn = true;
|
||||
eperson.requireCertificate = false;
|
||||
eperson.selfRegistered = true;
|
||||
this.subs.push(
|
||||
combineLatest([
|
||||
this.epersonDataService.createEPersonForToken(eperson, token).pipe(
|
||||
getFirstCompletedRemoteData(),
|
||||
),
|
||||
this.externalLoginService.getExternalAuthLocation(this.registrationData.registrationType),
|
||||
this.authService.getRedirectUrl().pipe(take(1)),
|
||||
])
|
||||
.subscribe(([rd, location, redirectRoute]) => {
|
||||
if (rd.hasFailed) {
|
||||
this.notificationService.error(
|
||||
this.translate.get('external-login-page.provide-email.create-account.notifications.error.header'),
|
||||
this.translate.get('external-login-page.provide-email.create-account.notifications.error.content'),
|
||||
);
|
||||
} else if (rd.hasSucceeded) {
|
||||
// set Redirect URL to User profile, so the user is redirected to the profile page after logging in
|
||||
this.authService.setRedirectUrl('/profile');
|
||||
const externalServerUrl = this.authService.getExternalServerRedirectUrl(
|
||||
this._window.nativeWindow.origin,
|
||||
redirectRoute,
|
||||
location,
|
||||
);
|
||||
// redirect to external registration type authentication url
|
||||
this.hardRedirectService.redirect(externalServerUrl);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.subs.filter(sub => hasValue(sub)).forEach((sub) => sub.unsubscribe());
|
||||
}
|
||||
}
|
@@ -0,0 +1,5 @@
|
||||
<h4>
|
||||
{{ "external-login.confirm-email-sent.header" | translate }}
|
||||
</h4>
|
||||
|
||||
<p class="mt-4" [innerHTML]="'external-login.confirm-email-sent.info' | translate"></p>
|
@@ -0,0 +1,73 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
CUSTOM_ELEMENTS_SCHEMA,
|
||||
EventEmitter,
|
||||
} from '@angular/core';
|
||||
import {
|
||||
ComponentFixture,
|
||||
TestBed,
|
||||
} from '@angular/core/testing';
|
||||
import {
|
||||
TranslateLoader,
|
||||
TranslateModule,
|
||||
TranslateService,
|
||||
} from '@ngx-translate/core';
|
||||
import { of } from 'rxjs';
|
||||
|
||||
import { TranslateLoaderMock } from '../../../shared/mocks/translate-loader.mock';
|
||||
import { ConfirmationSentComponent } from './confirmation-sent.component';
|
||||
|
||||
describe('ConfirmationSentComponent', () => {
|
||||
let component: ConfirmationSentComponent;
|
||||
let fixture: ComponentFixture<ConfirmationSentComponent>;
|
||||
let compiledTemplate: HTMLElement;
|
||||
|
||||
const translateServiceStub = {
|
||||
get: () => of('Mocked Translation Text'),
|
||||
instant: (key: any) => 'Mocked Translation Text',
|
||||
onLangChange: new EventEmitter(),
|
||||
onTranslationChange: new EventEmitter(),
|
||||
onDefaultLangChange: new EventEmitter(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
providers: [
|
||||
{ provide: TranslateService, useValue: translateServiceStub },
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
ConfirmationSentComponent,
|
||||
TranslateModule.forRoot({
|
||||
loader: {
|
||||
provide: TranslateLoader,
|
||||
useClass: TranslateLoaderMock,
|
||||
},
|
||||
}),
|
||||
],
|
||||
schemas: [CUSTOM_ELEMENTS_SCHEMA],
|
||||
})
|
||||
.compileComponents();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(ConfirmationSentComponent);
|
||||
component = fixture.componentInstance;
|
||||
compiledTemplate = fixture.nativeElement;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should render translated header', () => {
|
||||
const headerElement = compiledTemplate.querySelector('h4');
|
||||
expect(headerElement.textContent).toContain('Mocked Translation Text');
|
||||
});
|
||||
|
||||
it('should render translated info paragraph', () => {
|
||||
const infoParagraphElement = compiledTemplate.querySelector('p');
|
||||
expect(infoParagraphElement.innerHTML).toBeTruthy();
|
||||
});
|
||||
});
|
@@ -0,0 +1,19 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
} from '@angular/core';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
|
||||
@Component({
|
||||
selector: 'ds-confirmation-sent',
|
||||
templateUrl: './confirmation-sent.component.html',
|
||||
styleUrls: ['./confirmation-sent.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [TranslateModule],
|
||||
standalone: true,
|
||||
|
||||
})
|
||||
/**
|
||||
* Simple component that shows up a confirmation to the user.
|
||||
*/
|
||||
export class ConfirmationSentComponent { }
|
@@ -0,0 +1,37 @@
|
||||
<h4>
|
||||
{{ "external-login.provide-email.header" | translate }}
|
||||
</h4>
|
||||
|
||||
<form [formGroup]="emailForm" (ngSubmit)="submitForm()">
|
||||
<div class="form-group">
|
||||
<div class="form-row">
|
||||
<div class="col-12 my-2">
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
formControlName="email"
|
||||
class="form-control form-control-lg position-relative"
|
||||
[attr.aria-label]="'external-login.confirmation.email' | translate"
|
||||
/>
|
||||
|
||||
@if (emailForm.get('email').hasError('required') && emailForm.get('email').touched) {
|
||||
<div class="text-danger">
|
||||
{{ "external-login.confirmation.email-required" | translate }}
|
||||
</div>
|
||||
}
|
||||
@if (emailForm.get('email').hasError('email') && emailForm.get('email').touched) {
|
||||
<div class="text-danger">
|
||||
{{ "external-login.confirmation.email-invalid" | translate }}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="col-12">
|
||||
<button type="submit" class="btn btn-lg btn-primary w-100">
|
||||
{{ "external-login.provide-email.button.label" | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
@@ -0,0 +1,68 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
|
||||
import {
|
||||
ComponentFixture,
|
||||
TestBed,
|
||||
} from '@angular/core/testing';
|
||||
import { FormBuilder } from '@angular/forms';
|
||||
import {
|
||||
TranslateLoader,
|
||||
TranslateModule,
|
||||
} from '@ngx-translate/core';
|
||||
|
||||
import { TranslateLoaderMock } from '../../../shared/mocks/translate-loader.mock';
|
||||
import { ExternalLoginService } from '../../services/external-login.service';
|
||||
import { ProvideEmailComponent } from './provide-email.component';
|
||||
|
||||
describe('ProvideEmailComponent', () => {
|
||||
let component: ProvideEmailComponent;
|
||||
let fixture: ComponentFixture<ProvideEmailComponent>;
|
||||
let externalLoginServiceSpy: jasmine.SpyObj<ExternalLoginService>;
|
||||
|
||||
beforeEach(async () => {
|
||||
const externalLoginService = jasmine.createSpyObj('ExternalLoginService', ['patchUpdateRegistration']);
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
providers: [
|
||||
FormBuilder,
|
||||
{ provide: ExternalLoginService, useValue: externalLoginService },
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
ProvideEmailComponent,
|
||||
TranslateModule.forRoot({
|
||||
loader: {
|
||||
provide: TranslateLoader,
|
||||
useClass: TranslateLoaderMock,
|
||||
},
|
||||
}),
|
||||
],
|
||||
schemas: [CUSTOM_ELEMENTS_SCHEMA],
|
||||
})
|
||||
.compileComponents();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(ProvideEmailComponent);
|
||||
component = fixture.componentInstance;
|
||||
externalLoginServiceSpy = TestBed.inject(ExternalLoginService) as jasmine.SpyObj<ExternalLoginService>;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should call externalLoginService.patchUpdateRegistration when form is submitted with valid email', () => {
|
||||
const email = 'test@example.com';
|
||||
component.emailForm.setValue({ email });
|
||||
component.registrationId = '123';
|
||||
component.token = '456';
|
||||
fixture.detectChanges();
|
||||
|
||||
const button = fixture.nativeElement.querySelector('button[type="submit"]');
|
||||
button.click();
|
||||
|
||||
expect(externalLoginServiceSpy.patchUpdateRegistration).toHaveBeenCalledWith([email], 'email', component.registrationId, component.token, 'add');
|
||||
});
|
||||
});
|
@@ -0,0 +1,76 @@
|
||||
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,
|
||||
ReactiveFormsModule,
|
||||
],
|
||||
standalone: true,
|
||||
})
|
||||
/**
|
||||
* This component provides a proper field to submit the email that will be updated for its registration token
|
||||
*/
|
||||
export class ProvideEmailComponent implements OnDestroy {
|
||||
/**
|
||||
* The form group for the email input
|
||||
*/
|
||||
emailForm: FormGroup;
|
||||
/**
|
||||
* The registration id
|
||||
*/
|
||||
@Input() registrationId: string;
|
||||
/**
|
||||
* The token from the URL
|
||||
*/
|
||||
@Input() token: string;
|
||||
/**
|
||||
* The subscriptions to unsubscribe from
|
||||
*/
|
||||
subs: Subscription[] = [];
|
||||
|
||||
constructor(
|
||||
private formBuilder: FormBuilder,
|
||||
private externalLoginService: ExternalLoginService,
|
||||
) {
|
||||
this.emailForm = this.formBuilder.group({
|
||||
email: ['', [Validators.required, Validators.email]],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the user's email in the registration data.
|
||||
* @returns void
|
||||
*/
|
||||
submitForm() {
|
||||
this.emailForm.markAllAsTouched();
|
||||
if (this.emailForm.valid) {
|
||||
const email = this.emailForm.get('email').value;
|
||||
this.subs.push(this.externalLoginService.patchUpdateRegistration([email], 'email', this.registrationId, this.token, 'add')
|
||||
.subscribe());
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.subs.filter(sub => hasValue(sub)).forEach((sub) => sub.unsubscribe());
|
||||
}
|
||||
}
|
@@ -0,0 +1,52 @@
|
||||
<div class="row">
|
||||
<h4>{{ 'external-login.confirmation.header' | translate }}</h4>
|
||||
</div>
|
||||
<div class="row justify-content-center">
|
||||
<ng-container *ngComponentOutlet="getExternalLoginConfirmationType(); injector: objectInjector;">
|
||||
</ng-container>
|
||||
</div>
|
||||
<ds-alert class="container mt-2" [type]="AlertTypeEnum.Info" [attr.data-test]="'info-text'">
|
||||
{{ informationText }}
|
||||
</ds-alert>
|
||||
<div class="row d-flex justify-content-center">
|
||||
<div class="col-6 d-flex">
|
||||
<div class="col d-flex justify-content-center align-items-center">
|
||||
@if (registrationData.email) {
|
||||
<ds-confirm-email [registrationData]="registrationData" [token]="token"></ds-confirm-email>
|
||||
} @else {
|
||||
<ds-provide-email [registrationId]="registrationData.id" [token]="token"></ds-provide-email>
|
||||
}
|
||||
</div>
|
||||
@if (hasAuthMethodTypes | async) {
|
||||
<div class="col-1 d-flex justify-content-center align-items-center">
|
||||
<h4 class="mt-2">{{ 'external-login.component.or' | translate }}</h4>
|
||||
</div>
|
||||
<div class="col d-flex justify-content-center align-items-center">
|
||||
<button data-test="open-modal" class="btn block btn-lg btn-primary" (click)="openLoginModal(loginModal)">
|
||||
{{ 'external-login.connect-to-existing-account.label' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ng-template #loginModal let-c="close" let-d="dismiss">
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title text-info"> {{ 'external-login.connect-to-existing-account.label' | translate }}</h4>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="row justify-content-center">
|
||||
<ds-log-in
|
||||
[excludedAuthMethod]="relatedAuthMethod"
|
||||
[showRegisterLink]="false"
|
||||
[isStandalonePage]="true"
|
||||
></ds-log-in>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-primary btn-sm" (click)="c('Close click');clearRedirectUrl()">
|
||||
<i class="fa fa-times" aria-hidden="true"></i>
|
||||
{{ 'external-login.modal.label.close' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</ng-template>
|
@@ -0,0 +1,166 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { EventEmitter } from '@angular/core';
|
||||
import {
|
||||
ComponentFixture,
|
||||
TestBed,
|
||||
} from '@angular/core/testing';
|
||||
import { FormBuilder } from '@angular/forms';
|
||||
import { By } from '@angular/platform-browser';
|
||||
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
|
||||
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { StoreModule } from '@ngrx/store';
|
||||
import { provideMockStore } from '@ngrx/store/testing';
|
||||
import {
|
||||
TranslateModule,
|
||||
TranslateService,
|
||||
} from '@ngx-translate/core';
|
||||
import { of as observableOf } from 'rxjs';
|
||||
|
||||
import { storeModuleConfig } from '../../app.reducer';
|
||||
import { authReducer } from '../../core/auth/auth.reducer';
|
||||
import { AuthService } from '../../core/auth/auth.service';
|
||||
import { AuthMethodsService } from '../../core/auth/auth-methods.service';
|
||||
import { AuthMethod } from '../../core/auth/models/auth.method';
|
||||
import { AuthMethodType } from '../../core/auth/models/auth.method-type';
|
||||
import { AuthRegistrationType } from '../../core/auth/models/auth.registration-type';
|
||||
import { MetadataValue } from '../../core/shared/metadata.models';
|
||||
import { Registration } from '../../core/shared/registration.model';
|
||||
import { AuthMethodTypeComponent } from '../../shared/log-in/methods/auth-methods.type';
|
||||
import { AuthServiceMock } from '../../shared/mocks/auth.service.mock';
|
||||
import { BrowserOnlyPipe } from '../../shared/utils/browser-only.pipe';
|
||||
import { ConfirmEmailComponent } from '../email-confirmation/confirm-email/confirm-email.component';
|
||||
import { OrcidConfirmationComponent } from '../registration-types/orcid-confirmation/orcid-confirmation.component';
|
||||
import { ExternalLogInComponent } from './external-log-in.component';
|
||||
|
||||
describe('ExternalLogInComponent', () => {
|
||||
let component: ExternalLogInComponent;
|
||||
let fixture: ComponentFixture<ExternalLogInComponent>;
|
||||
let modalService: NgbModal = jasmine.createSpyObj('modalService', ['open']);
|
||||
let authServiceStub: jasmine.SpyObj<AuthService>;
|
||||
let authMethodsServiceStub: jasmine.SpyObj<AuthMethodsService>;
|
||||
let mockAuthMethodsArray: AuthMethod[] = [
|
||||
{ id: 'password', authMethodType: AuthMethodType.Password, position: 2 } as AuthMethod,
|
||||
{ id: 'shibboleth', authMethodType: AuthMethodType.Shibboleth, position: 1 } as AuthMethod,
|
||||
{ id: 'oidc', authMethodType: AuthMethodType.Oidc, position: 3 } as AuthMethod,
|
||||
{ id: 'ip', authMethodType: AuthMethodType.Ip, position: 4 } as AuthMethod,
|
||||
];
|
||||
|
||||
const initialState = {
|
||||
core: {
|
||||
auth: {
|
||||
authMethods: mockAuthMethodsArray,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
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(async () => {
|
||||
authServiceStub = jasmine.createSpyObj('AuthService', ['getAuthenticationMethods']);
|
||||
authMethodsServiceStub = jasmine.createSpyObj('AuthMethodsService', ['getAuthMethods']);
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
TranslateModule.forRoot({}),
|
||||
BrowserOnlyPipe,
|
||||
ExternalLogInComponent,
|
||||
OrcidConfirmationComponent,
|
||||
BrowserAnimationsModule,
|
||||
StoreModule.forRoot(authReducer, storeModuleConfig),
|
||||
],
|
||||
providers: [
|
||||
{ provide: TranslateService, useValue: translateServiceStub },
|
||||
{ provide: AuthService, useValue: new AuthServiceMock() },
|
||||
{ provide: NgbModal, useValue: modalService },
|
||||
FormBuilder,
|
||||
provideMockStore({ initialState }),
|
||||
],
|
||||
})
|
||||
.overrideComponent(ExternalLogInComponent, {
|
||||
remove: {
|
||||
imports: [ConfirmEmailComponent],
|
||||
},
|
||||
})
|
||||
.compileComponents();
|
||||
});
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(ExternalLogInComponent);
|
||||
component = fixture.componentInstance;
|
||||
component.registrationData = Object.assign(new Registration(), registrationDataMock);
|
||||
component.registrationType = registrationDataMock.registrationType;
|
||||
|
||||
let mockAuthMethods = new Map<AuthMethodType, AuthMethodTypeComponent>();
|
||||
mockAuthMethods.set(AuthMethodType.Password, {} as AuthMethodTypeComponent);
|
||||
mockAuthMethods.set(AuthMethodType.Shibboleth, {} as AuthMethodTypeComponent);
|
||||
mockAuthMethods.set(AuthMethodType.Oidc, {} as AuthMethodTypeComponent);
|
||||
mockAuthMethods.set(AuthMethodType.Ip, {} as AuthMethodTypeComponent);
|
||||
component.authMethods = mockAuthMethods;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
fixture.detectChanges();
|
||||
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.debugElement.query(By.css('[data-test="open-modal"]'));
|
||||
|
||||
expect(button).not.toBeNull('Connect to existing account button should be in the DOM');
|
||||
|
||||
button.nativeElement.click();
|
||||
expect(modalService.open).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should render the template with the translated informationText', () => {
|
||||
component.informationText = 'Info Text';
|
||||
fixture.detectChanges();
|
||||
const infoText = fixture.debugElement.query(By.css('[data-test="info-text"]'));
|
||||
expect(infoText.nativeElement.innerHTML).toContain('Info Text');
|
||||
});
|
||||
});
|
||||
|
||||
|
@@ -0,0 +1,206 @@
|
||||
import {
|
||||
AsyncPipe,
|
||||
NgComponentOutlet,
|
||||
} 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 { Observable } from 'rxjs';
|
||||
import { map } from 'rxjs/operators';
|
||||
|
||||
import { AuthService } from '../../core/auth/auth.service';
|
||||
import { AuthMethodsService } from '../../core/auth/auth-methods.service';
|
||||
import { AuthMethodType } from '../../core/auth/models/auth.method-type';
|
||||
import { AuthRegistrationType } from '../../core/auth/models/auth.registration-type';
|
||||
import { Registration } from '../../core/shared/registration.model';
|
||||
import { AlertComponent } from '../../shared/alert/alert.component';
|
||||
import { AlertType } from '../../shared/alert/alert-type';
|
||||
import {
|
||||
hasValue,
|
||||
isEmpty,
|
||||
} from '../../shared/empty.util';
|
||||
import { AuthMethodTypeComponent } from '../../shared/log-in/methods/auth-methods.type';
|
||||
import { ThemedLogInComponent } from '../../shared/log-in/themed-log-in.component';
|
||||
import {
|
||||
ExternalLoginTypeComponent,
|
||||
getExternalLoginConfirmationType,
|
||||
} from '../decorators/external-log-in.methods-decorator';
|
||||
import { ConfirmEmailComponent } from '../email-confirmation/confirm-email/confirm-email.component';
|
||||
import { ProvideEmailComponent } from '../email-confirmation/provide-email/provide-email.component';
|
||||
|
||||
@Component({
|
||||
selector: 'ds-external-log-in',
|
||||
templateUrl: './external-log-in.component.html',
|
||||
styleUrls: ['./external-log-in.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
ProvideEmailComponent,
|
||||
AlertComponent,
|
||||
TranslateModule,
|
||||
ConfirmEmailComponent,
|
||||
ThemedLogInComponent,
|
||||
NgComponentOutlet,
|
||||
AsyncPipe,
|
||||
],
|
||||
standalone: true,
|
||||
})
|
||||
/**
|
||||
* This component is responsible to handle the external-login depending on the RegistrationData details provided
|
||||
*/
|
||||
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 authMethods taken from the configuration
|
||||
* @memberof ExternalLogInComponent
|
||||
*/
|
||||
@Input() authMethods: Map<AuthMethodType, AuthMethodTypeComponent>;
|
||||
/**
|
||||
* 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;
|
||||
/**
|
||||
* The observable to check if any auth method type is configured
|
||||
*/
|
||||
hasAuthMethodTypes: Observable<boolean>;
|
||||
|
||||
constructor(
|
||||
private injector: Injector,
|
||||
private translate: TranslateService,
|
||||
private modalService: NgbModal,
|
||||
private authService: AuthService,
|
||||
private authMethodsService: AuthMethodsService,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
this.hasAuthMethodTypes =
|
||||
this.authMethodsService.getAuthMethods(this.authMethods, this.relatedAuthMethod)
|
||||
.pipe(map(methods => methods.length > 0));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the registration type to be rendered
|
||||
*/
|
||||
getExternalLoginConfirmationType(): ExternalLoginTypeComponent {
|
||||
return getExternalLoginConfirmationType(this.registrationType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the login modal and sets the redirect URL to '/review-account'.
|
||||
* On modal dismissed/closed, the redirect URL is cleared.
|
||||
* @param content - The content to be displayed in the modal.
|
||||
*/
|
||||
openLoginModal(content: any) {
|
||||
this.modalRef = this.modalService.open(content);
|
||||
this.authService.setRedirectUrl(`/review-account/${this.token}`);
|
||||
this.modalRef.dismissed.subscribe(() => {
|
||||
this.clearRedirectUrl();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the redirect URL stored in the authentication service.
|
||||
*/
|
||||
clearRedirectUrl() {
|
||||
this.authService.clearRedirectUrl();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.modalRef?.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
53
src/app/external-log-in/guards/registration-token-guard.ts
Normal file
53
src/app/external-log-in/guards/registration-token-guard.ts
Normal file
@@ -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<boolean> => {
|
||||
const epersonRegistrationService = inject(EpersonRegistrationService);
|
||||
const router = inject(Router);
|
||||
if (route.params.token) {
|
||||
return epersonRegistrationService
|
||||
.searchByTokenAndHandleError(route.params.token)
|
||||
.pipe(
|
||||
getFirstCompletedRemoteData(),
|
||||
map(
|
||||
(data: RemoteData<Registration>) => {
|
||||
if (data.hasSucceeded && hasValue(data)) {
|
||||
return true;
|
||||
} else {
|
||||
router.navigate(['/404']);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
} else {
|
||||
router.navigate(['/404']);
|
||||
return of(false);
|
||||
}
|
||||
};
|
171
src/app/external-log-in/guards/registration-token.guard.spec.ts
Normal file
171
src/app/external-log-in/guards/registration-token.guard.spec.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import {
|
||||
fakeAsync,
|
||||
TestBed,
|
||||
} 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 {
|
||||
createFailedRemoteDataObject$,
|
||||
createSuccessfulRemoteDataObject$,
|
||||
} from '../../shared/remote-data.utils';
|
||||
import { registrationTokenGuard } from './registration-token-guard';
|
||||
|
||||
describe('RegistrationTokenGuard',
|
||||
() => {
|
||||
const route = new RouterMock();
|
||||
const registrationWithGroups = Object.assign(new Registration(),
|
||||
{
|
||||
email: 'test@email.org',
|
||||
token: 'test-token',
|
||||
});
|
||||
const epersonRegistrationService = jasmine.createSpyObj('epersonRegistrationService', {
|
||||
searchByTokenAndHandleError: createSuccessfulRemoteDataObject$(registrationWithGroups),
|
||||
});
|
||||
const authService = {
|
||||
getAuthenticatedUserFromStore: () => observableOf(ePerson),
|
||||
setRedirectUrl: () => {
|
||||
return true;
|
||||
},
|
||||
} as any;
|
||||
const ePerson = Object.assign(new EPerson(), {
|
||||
id: 'test-eperson',
|
||||
uuid: 'test-eperson',
|
||||
});
|
||||
|
||||
describe('when token provided', () => {
|
||||
|
||||
let arouteStub = {
|
||||
snapshot: {
|
||||
params: {
|
||||
token: '123456789',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [{ provide: Router, useValue: route },
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: arouteStub,
|
||||
},
|
||||
{ provide: EpersonRegistrationService, useValue: epersonRegistrationService },
|
||||
{ provide: AuthService, useValue: authService },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('can activate must return true when registration data includes groups', ((async) => {
|
||||
const activatedRoute = TestBed.inject(ActivatedRoute);
|
||||
epersonRegistrationService.searchByTokenAndHandleError.and.returnValue(createSuccessfulRemoteDataObject$(registrationWithGroups));
|
||||
activatedRoute.snapshot.params.token = arouteStub.snapshot.params.token;
|
||||
|
||||
const result$ = TestBed.runInInjectionContext(() => {
|
||||
return registrationTokenGuard(activatedRoute.snapshot, {} as RouterStateSnapshot) as Observable<boolean>;
|
||||
});
|
||||
|
||||
result$.subscribe((result) => {
|
||||
expect(result).toBeTrue();
|
||||
async();
|
||||
});
|
||||
}));
|
||||
});
|
||||
|
||||
describe('when no token provided', () => {
|
||||
|
||||
let noTokenRoute = {
|
||||
snapshot: {
|
||||
params: {
|
||||
token: null,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const registrationWithDifferentUserFromLoggedIn = Object.assign(new Registration(), {
|
||||
email: 't1@email.org',
|
||||
token: 'test-token',
|
||||
});
|
||||
|
||||
const epersonDifferentUserFromLoggedIn = jasmine.createSpyObj('epersonRegistrationService', {
|
||||
searchByTokenAndHandleError: observableOf(registrationWithDifferentUserFromLoggedIn),
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [{ provide: Router, useValue: route },
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: noTokenRoute,
|
||||
},
|
||||
{ provide: EpersonRegistrationService, useValue: epersonDifferentUserFromLoggedIn },
|
||||
{ provide: AuthService, useValue: authService },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('can activate must return false when registration data includes groups', fakeAsync(() => {
|
||||
let activatedRoute = TestBed.inject(ActivatedRoute);
|
||||
|
||||
const result$ = TestBed.runInInjectionContext(() => {
|
||||
return registrationTokenGuard(activatedRoute.snapshot, {} as RouterStateSnapshot) as Observable<boolean>;
|
||||
});
|
||||
|
||||
let output = null;
|
||||
result$.subscribe((result) => (output = result));
|
||||
expect(output).toBeFalse();
|
||||
expect(route.navigate).toHaveBeenCalledWith(['/404']);
|
||||
}));
|
||||
});
|
||||
|
||||
describe('when invalid token provided', () => {
|
||||
let invalidTokenRoute = {
|
||||
snapshot: {
|
||||
params: {
|
||||
token: 'invalid-token',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const failedRegistationService = jasmine.createSpyObj('epersonRegistrationService', {
|
||||
searchByTokenAndHandleError: createFailedRemoteDataObject$('invalid token', 404),
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [{ provide: Router, useValue: route },
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: invalidTokenRoute,
|
||||
},
|
||||
{ provide: EpersonRegistrationService, useValue: failedRegistationService },
|
||||
{ provide: AuthService, useValue: authService },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('can activate must navigate through 404 when failed response received', fakeAsync(() => {
|
||||
let activatedRoute = TestBed.inject(ActivatedRoute);
|
||||
|
||||
const result$ = TestBed.runInInjectionContext(() => {
|
||||
return registrationTokenGuard(activatedRoute.snapshot, {} as RouterStateSnapshot) as Observable<boolean>;
|
||||
});
|
||||
|
||||
result$.subscribe((_) => { });
|
||||
expect(route.navigate).toHaveBeenCalledWith(['/404']);
|
||||
}));
|
||||
});
|
||||
|
||||
});
|
@@ -0,0 +1,45 @@
|
||||
import { AuthMethodType } from '../../core/auth/models/auth.method-type';
|
||||
import { MetadataValue } from '../../core/shared/metadata.models';
|
||||
import { Registration } from '../../core/shared/registration.model';
|
||||
|
||||
export const mockRegistrationDataModel: Registration = Object.assign(
|
||||
new Registration(),
|
||||
{
|
||||
id: '3',
|
||||
email: 'user@institution.edu',
|
||||
user: '028dcbb8-0da2-4122-a0ea-254be49ca107',
|
||||
registrationType: AuthMethodType.Orcid,
|
||||
netId: '0000-1111-2222-3333',
|
||||
registrationMetadata: {
|
||||
'eperson.firstname': [
|
||||
Object.assign(new MetadataValue(), {
|
||||
value: 'User',
|
||||
language: null,
|
||||
authority: '',
|
||||
confidence: -1,
|
||||
place: -1,
|
||||
overrides: 'User',
|
||||
}),
|
||||
],
|
||||
'eperson.lastname': [
|
||||
Object.assign(new MetadataValue(), {
|
||||
value: 'Power',
|
||||
language: null,
|
||||
authority: '',
|
||||
confidence: -1,
|
||||
place: -1,
|
||||
}),
|
||||
],
|
||||
'email': [
|
||||
{
|
||||
value: 'power-user@orcid.org',
|
||||
language: null,
|
||||
authority: '',
|
||||
confidence: -1,
|
||||
place: -1,
|
||||
overrides: 'power-user@dspace.org',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
);
|
@@ -0,0 +1,35 @@
|
||||
<form class="form-login"
|
||||
[formGroup]="form" novalidate>
|
||||
<label class="font-weight-bold mb-0 text-uppercase">{{ registrationData.registrationType }}</label>
|
||||
<input [attr.aria-label]="'external-login-page.orcid-confirmation.netid.label' | translate"
|
||||
autocomplete="off"
|
||||
autofocus
|
||||
class="form-control form-control-lg position-relative mb-2"
|
||||
formControlName="netId"
|
||||
[placeholder]="'external-login-page.orcid-confirmation.netid.placeholder' | translate"
|
||||
type="text"
|
||||
[attr.data-test]="'netId' | dsBrowserOnly">
|
||||
<label class="font-weight-bold mb-0">{{"external-login-page.orcid-confirmation.lastname" | translate}}</label>
|
||||
<input [attr.aria-label]="'external-login-page.orcid-confirmation.lastname.label' | translate"
|
||||
autocomplete="off"
|
||||
class="form-control form-control-lg position-relative mb-2"
|
||||
formControlName="lastname"
|
||||
type="text"
|
||||
[attr.data-test]="'lastname' | dsBrowserOnly">
|
||||
<label class="font-weight-bold mb-0">{{"external-login-page.orcid-confirmation.firstname" | translate}}</label>
|
||||
<input [attr.aria-label]="'external-login-page.orcid-confirmation.firstname.label' | translate"
|
||||
autocomplete="off"
|
||||
class="form-control form-control-lg position-relative mb-2"
|
||||
formControlName="firstname"
|
||||
type="text"
|
||||
[attr.data-test]="'firstname' | dsBrowserOnly">
|
||||
@if (registrationData?.email) {
|
||||
<label class="font-weight-bold mb-0">{{"external-login-page.orcid-confirmation.email" | translate}}</label>
|
||||
<input [attr.aria-label]="'external-login-page.orcid-confirmation.email.label' | translate"
|
||||
autocomplete="off"
|
||||
class="form-control form-control-lg position-relative"
|
||||
formControlName="email"
|
||||
type="email"
|
||||
[attr.data-test]="'email' | dsBrowserOnly">
|
||||
}
|
||||
</form>
|
@@ -0,0 +1,76 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { NO_ERRORS_SCHEMA } from '@angular/core';
|
||||
import {
|
||||
ComponentFixture,
|
||||
TestBed,
|
||||
} from '@angular/core/testing';
|
||||
import {
|
||||
FormBuilder,
|
||||
FormGroup,
|
||||
} from '@angular/forms';
|
||||
import {
|
||||
TranslateLoader,
|
||||
TranslateModule,
|
||||
} from '@ngx-translate/core';
|
||||
import { Registration } from 'src/app/core/shared/registration.model';
|
||||
|
||||
import { TranslateLoaderMock } from '../../../shared/mocks/translate-loader.mock';
|
||||
import { BrowserOnlyMockPipe } from '../../../shared/testing/browser-only-mock.pipe';
|
||||
import { mockRegistrationDataModel } from '../../models/registration-data.mock.model';
|
||||
import { OrcidConfirmationComponent } from './orcid-confirmation.component';
|
||||
|
||||
describe('OrcidConfirmationComponent', () => {
|
||||
let component: OrcidConfirmationComponent;
|
||||
let fixture: ComponentFixture<OrcidConfirmationComponent>;
|
||||
let model: Registration;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
providers: [
|
||||
FormBuilder,
|
||||
{ provide: 'registrationDataProvider', useValue: mockRegistrationDataModel },
|
||||
],
|
||||
imports: [
|
||||
OrcidConfirmationComponent,
|
||||
CommonModule,
|
||||
TranslateModule.forRoot({
|
||||
loader: {
|
||||
provide: TranslateLoader,
|
||||
useClass: TranslateLoaderMock,
|
||||
},
|
||||
}),
|
||||
BrowserOnlyMockPipe,
|
||||
],
|
||||
schemas: [NO_ERRORS_SCHEMA],
|
||||
})
|
||||
.compileComponents();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(OrcidConfirmationComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should initialize the form with disabled fields', () => {
|
||||
expect(component.form).toBeInstanceOf(FormGroup);
|
||||
expect(component.form.controls.netId.disabled).toBeTrue();
|
||||
expect(component.form.controls.firstname.disabled).toBeTrue();
|
||||
expect(component.form.controls.lastname.disabled).toBeTrue();
|
||||
expect(component.form.controls.email.disabled).toBeTrue();
|
||||
});
|
||||
|
||||
|
||||
it('should initialize the form with null email as an empty string', () => {
|
||||
component.registrationData.email = null;
|
||||
component.ngOnInit();
|
||||
fixture.detectChanges();
|
||||
const emailFormControl = component.form.get('email');
|
||||
expect(emailFormControl.value).toBe('');
|
||||
});
|
||||
|
||||
});
|
@@ -0,0 +1,78 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
Inject,
|
||||
OnInit,
|
||||
} from '@angular/core';
|
||||
import {
|
||||
FormBuilder,
|
||||
FormGroup,
|
||||
ReactiveFormsModule,
|
||||
} from '@angular/forms';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
|
||||
import { Registration } from '../../../core/shared/registration.model';
|
||||
import { BrowserOnlyPipe } from '../../../shared/utils/browser-only.pipe';
|
||||
import { ExternalLoginMethodEntryComponent } from '../../decorators/external-login-method-entry.component';
|
||||
|
||||
@Component({
|
||||
selector: 'ds-orcid-confirmation',
|
||||
templateUrl: './orcid-confirmation.component.html',
|
||||
styleUrls: ['./orcid-confirmation.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
ReactiveFormsModule,
|
||||
TranslateModule,
|
||||
BrowserOnlyPipe,
|
||||
],
|
||||
standalone: true,
|
||||
})
|
||||
/**
|
||||
* This component is responsible to show the registered data inside the registration token to the user
|
||||
*/
|
||||
export class OrcidConfirmationComponent extends ExternalLoginMethodEntryComponent implements OnInit {
|
||||
|
||||
/**
|
||||
* The form containing the user's data
|
||||
*/
|
||||
public form: FormGroup;
|
||||
|
||||
/**
|
||||
* @param injectedRegistrationDataObject Registration object provided
|
||||
* @param formBuilder To build the form
|
||||
*/
|
||||
constructor(
|
||||
@Inject('registrationDataProvider') protected injectedRegistrationDataObject: Registration,
|
||||
private formBuilder: FormBuilder,
|
||||
) {
|
||||
super(injectedRegistrationDataObject);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the form with disabled fields
|
||||
*/
|
||||
ngOnInit(): void {
|
||||
this.form = this.formBuilder.group({
|
||||
netId: [{ value: this.registrationData.netId, disabled: true }],
|
||||
firstname: [{ value: this.getFirstname(), disabled: true }],
|
||||
lastname: [{ value: this.getLastname(), disabled: true }],
|
||||
email: [{ value: this.registrationData?.email || '', disabled: true }], // email can be null
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the firstname of the user from the registration metadata
|
||||
* @returns the firstname of the user
|
||||
*/
|
||||
private getFirstname(): string {
|
||||
return this.registrationData.registrationMetadata?.['eperson.firstname']?.[0]?.value || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the lastname of the user from the registration metadata
|
||||
* @returns the lastname of the user
|
||||
*/
|
||||
private getLastname(): string {
|
||||
return this.registrationData.registrationMetadata?.['eperson.lastname']?.[0]?.value || '';
|
||||
}
|
||||
}
|
@@ -0,0 +1,52 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import {
|
||||
ActivatedRouteSnapshot,
|
||||
RouterStateSnapshot,
|
||||
} from '@angular/router';
|
||||
|
||||
import { EpersonRegistrationService } from '../../core/data/eperson-registration.service';
|
||||
import { Registration } from '../../core/shared/registration.model';
|
||||
import {
|
||||
createSuccessfulRemoteDataObject,
|
||||
createSuccessfulRemoteDataObject$,
|
||||
} from '../../shared/remote-data.utils';
|
||||
import { RegistrationDataResolver } from './registration-data.resolver';
|
||||
|
||||
describe('RegistrationDataResolver', () => {
|
||||
let resolver: RegistrationDataResolver;
|
||||
let epersonRegistrationServiceSpy: jasmine.SpyObj<EpersonRegistrationService>;
|
||||
const registrationMock = Object.assign(new Registration(), {
|
||||
email: 'test@user.com',
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
const spy = jasmine.createSpyObj('EpersonRegistrationService', ['searchByTokenAndHandleError']);
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
RegistrationDataResolver,
|
||||
{ provide: EpersonRegistrationService, useValue: spy },
|
||||
],
|
||||
});
|
||||
resolver = TestBed.inject(RegistrationDataResolver);
|
||||
epersonRegistrationServiceSpy = TestBed.inject(EpersonRegistrationService) as jasmine.SpyObj<EpersonRegistrationService>;
|
||||
});
|
||||
|
||||
it('should be created', () => {
|
||||
expect(resolver).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should resolve registration data based on a token', () => {
|
||||
const token = 'abc123';
|
||||
const registrationRD$ = createSuccessfulRemoteDataObject$(registrationMock);
|
||||
epersonRegistrationServiceSpy.searchByTokenAndHandleError.and.returnValue(registrationRD$);
|
||||
const route = new ActivatedRouteSnapshot();
|
||||
route.params = { token: token };
|
||||
const state = {} as RouterStateSnapshot;
|
||||
|
||||
resolver.resolve(route, state).subscribe((data) => {
|
||||
expect(data).toEqual(createSuccessfulRemoteDataObject(registrationMock));
|
||||
});
|
||||
expect(epersonRegistrationServiceSpy.searchByTokenAndHandleError).toHaveBeenCalledWith(token);
|
||||
});
|
||||
});
|
@@ -0,0 +1,43 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import {
|
||||
ActivatedRouteSnapshot,
|
||||
Resolve,
|
||||
RouterStateSnapshot,
|
||||
} from '@angular/router';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
import { EpersonRegistrationService } from '../../core/data/eperson-registration.service';
|
||||
import { RemoteData } from '../../core/data/remote-data';
|
||||
import { getFirstCompletedRemoteData } from '../../core/shared/operators';
|
||||
import { Registration } from '../../core/shared/registration.model';
|
||||
import { hasValue } from '../../shared/empty.util';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
/**
|
||||
* Resolver for retrieving registration data based on a token.
|
||||
*/
|
||||
export class RegistrationDataResolver implements Resolve<RemoteData<Registration>> {
|
||||
|
||||
/**
|
||||
* Constructor for RegistrationDataResolver.
|
||||
* @param epersonRegistrationService The EpersonRegistrationService used to retrieve registration data.
|
||||
*/
|
||||
constructor(private epersonRegistrationService: EpersonRegistrationService) {}
|
||||
|
||||
/**
|
||||
* Resolves registration data based on a token.
|
||||
* @param route The ActivatedRouteSnapshot containing the token parameter.
|
||||
* @param state The RouterStateSnapshot.
|
||||
* @returns An Observable of Registration.
|
||||
*/
|
||||
resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<RemoteData<Registration>> {
|
||||
const token = route.params.token;
|
||||
if (hasValue(token)) {
|
||||
return this.epersonRegistrationService.searchByTokenAndHandleError(token).pipe(
|
||||
getFirstCompletedRemoteData(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
@@ -0,0 +1,92 @@
|
||||
import { NO_ERRORS_SCHEMA } from '@angular/core';
|
||||
import {
|
||||
fakeAsync,
|
||||
TestBed,
|
||||
tick,
|
||||
} from '@angular/core/testing';
|
||||
import { Router } from '@angular/router';
|
||||
import { provideMockStore } from '@ngrx/store/testing';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { getTestScheduler } from 'jasmine-marbles';
|
||||
import { of as observableOf } from 'rxjs';
|
||||
import { TestScheduler } from 'rxjs/testing';
|
||||
|
||||
import { EpersonRegistrationService } from '../../core/data/eperson-registration.service';
|
||||
import { RemoteData } from '../../core/data/remote-data';
|
||||
import { NoContent } from '../../core/shared/NoContent.model';
|
||||
import { Registration } from '../../core/shared/registration.model';
|
||||
import { RouterMock } from '../../shared/mocks/router.mock';
|
||||
import { NotificationsService } from '../../shared/notifications/notifications.service';
|
||||
import {
|
||||
createFailedRemoteDataObject,
|
||||
createSuccessfulRemoteDataObject$,
|
||||
} from '../../shared/remote-data.utils';
|
||||
import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub';
|
||||
import { ExternalLoginService } from './external-login.service';
|
||||
|
||||
describe('ExternalLoginService', () => {
|
||||
let service: ExternalLoginService;
|
||||
let epersonRegistrationService;
|
||||
let router: any;
|
||||
let notificationService;
|
||||
let translate;
|
||||
let scheduler: TestScheduler;
|
||||
|
||||
const values = ['value1', 'value2'];
|
||||
const field = 'field1';
|
||||
const registrationId = 'registrationId1';
|
||||
const token = 'token1';
|
||||
const operation = 'add';
|
||||
|
||||
beforeEach(() => {
|
||||
epersonRegistrationService = jasmine.createSpyObj('epersonRegistrationService', {
|
||||
patchUpdateRegistration: createSuccessfulRemoteDataObject$(new Registration),
|
||||
});
|
||||
router = new RouterMock();
|
||||
notificationService = new NotificationsServiceStub();
|
||||
translate = jasmine.createSpyObj('TranslateService', ['get']);
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
ExternalLoginService,
|
||||
{ provide: EpersonRegistrationService, useValue: epersonRegistrationService },
|
||||
{ provide: Router, useValue: router },
|
||||
{ provide: NotificationsService, useValue: notificationService },
|
||||
{ provide: TranslateService, useValue: translate },
|
||||
provideMockStore(),
|
||||
],
|
||||
schemas: [NO_ERRORS_SCHEMA],
|
||||
});
|
||||
service = TestBed.inject(ExternalLoginService);
|
||||
scheduler = getTestScheduler();
|
||||
});
|
||||
|
||||
it('should be created', () => {
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should call epersonRegistrationService.patchUpdateRegistration with the correct parameters', () => {
|
||||
epersonRegistrationService.patchUpdateRegistration.and.returnValue(observableOf({} as RemoteData<Registration>));
|
||||
service.patchUpdateRegistration(values, field, registrationId, token, operation);
|
||||
expect(epersonRegistrationService.patchUpdateRegistration).toHaveBeenCalledWith(values, field, registrationId, token, operation);
|
||||
});
|
||||
|
||||
it('should navigate to /email-confirmation if the remote data has succeeded', () => {
|
||||
epersonRegistrationService.patchUpdateRegistration.and.returnValue(createSuccessfulRemoteDataObject$(new Registration()));
|
||||
scheduler.schedule(() => service.patchUpdateRegistration(values, field, registrationId, token, operation).subscribe());
|
||||
scheduler.flush();
|
||||
expect((router as any).navigate).toHaveBeenCalledWith(['/email-confirmation']);
|
||||
});
|
||||
|
||||
it('should show an error notification if the remote data has failed', fakeAsync(() => {
|
||||
const remoteData = createFailedRemoteDataObject<NoContent>('error message');
|
||||
epersonRegistrationService.patchUpdateRegistration.and.returnValue(observableOf(remoteData));
|
||||
translate.get.and.returnValue(observableOf('error message'));
|
||||
|
||||
let result = null;
|
||||
service.patchUpdateRegistration(values, field, registrationId, token, operation).subscribe((data) => (result = data));
|
||||
tick(100);
|
||||
expect(result).toEqual(remoteData);
|
||||
expect(notificationService.error).toHaveBeenCalled();
|
||||
}));
|
||||
});
|
77
src/app/external-log-in/services/external-login.service.ts
Normal file
77
src/app/external-log-in/services/external-login.service.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import {
|
||||
select,
|
||||
Store,
|
||||
} from '@ngrx/store';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import {
|
||||
filter,
|
||||
map,
|
||||
Observable,
|
||||
} from 'rxjs';
|
||||
import { AuthMethod } from 'src/app/core/auth/models/auth.method';
|
||||
import { getAuthenticationMethods } from 'src/app/core/auth/selectors';
|
||||
import { CoreState } from 'src/app/core/core-state.model';
|
||||
|
||||
import { EpersonRegistrationService } from '../../core/data/eperson-registration.service';
|
||||
import { RemoteData } from '../../core/data/remote-data';
|
||||
import { NoContent } from '../../core/shared/NoContent.model';
|
||||
import { getFirstCompletedRemoteData } from '../../core/shared/operators';
|
||||
import { NotificationsService } from '../../shared/notifications/notifications.service';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
/**
|
||||
* This service is responsible to communicate with the epersonRegistrationService to update the RegistrationData
|
||||
* provided by the user.
|
||||
*/
|
||||
export class ExternalLoginService {
|
||||
|
||||
constructor(
|
||||
private epersonRegistrationService: EpersonRegistrationService,
|
||||
private router: Router,
|
||||
private notificationService: NotificationsService,
|
||||
private translate: TranslateService,
|
||||
private store: Store<CoreState>,
|
||||
) { }
|
||||
|
||||
/**
|
||||
* Update the registration data.
|
||||
* Send a patch request to the server to update the registration data.
|
||||
* @param values the values to update or add
|
||||
* @param field the filed to be updated
|
||||
* @param registrationId the registration id
|
||||
* @param token the registration token
|
||||
* @param operation operation to be performed
|
||||
*/
|
||||
patchUpdateRegistration(values: string[], field: string, registrationId: string, token: string, operation: 'add' | 'replace'): Observable<RemoteData<NoContent>> {
|
||||
const updatedValues = values.map((value) => value);
|
||||
return this.epersonRegistrationService.patchUpdateRegistration(updatedValues, field, registrationId, token, operation).pipe(
|
||||
getFirstCompletedRemoteData(),
|
||||
map((rd) => {
|
||||
if (rd.hasSucceeded) {
|
||||
this.router.navigate(['/email-confirmation']);
|
||||
}
|
||||
if (rd.hasFailed) {
|
||||
this.notificationService.error(this.translate.get('external-login-page.provide-email.notifications.error'));
|
||||
}
|
||||
return rd;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an Observable that emits the external authentication location for the given registration type.
|
||||
* @param registrationType The type of registration to get the external authentication location for.
|
||||
* @returns An Observable that emits the external authentication location as a string.
|
||||
*/
|
||||
getExternalAuthLocation(registrationType: string): Observable<string> {
|
||||
return this.store.pipe(
|
||||
select(getAuthenticationMethods),
|
||||
filter((methods: AuthMethod[]) => methods.length > 0),
|
||||
map((methods: AuthMethod[]) => methods.find((m: AuthMethod) => m.authMethodType.toString() === registrationType.toLocaleLowerCase()).location),
|
||||
);
|
||||
}
|
||||
}
|
@@ -0,0 +1,11 @@
|
||||
import { Routes } from '@angular/router';
|
||||
|
||||
import { ExternalLoginEmailConfirmationPageComponent } from './external-login-email-confirmation-page.component';
|
||||
|
||||
export const ROUTES: Routes = [
|
||||
{
|
||||
path: '',
|
||||
pathMatch: 'full',
|
||||
component: ExternalLoginEmailConfirmationPageComponent,
|
||||
},
|
||||
];
|
@@ -0,0 +1,3 @@
|
||||
<div class="container">
|
||||
<ds-confirmation-sent></ds-confirmation-sent>
|
||||
</div>
|
@@ -0,0 +1,48 @@
|
||||
import {
|
||||
ComponentFixture,
|
||||
TestBed,
|
||||
} from '@angular/core/testing';
|
||||
import {
|
||||
TranslateLoader,
|
||||
TranslateModule,
|
||||
} from '@ngx-translate/core';
|
||||
|
||||
import { ConfirmationSentComponent } from '../external-log-in/email-confirmation/confirmation-sent/confirmation-sent.component';
|
||||
import { TranslateLoaderMock } from '../shared/mocks/translate-loader.mock';
|
||||
import { ExternalLoginEmailConfirmationPageComponent } from './external-login-email-confirmation-page.component';
|
||||
|
||||
describe('ExternalLoginEmailConfirmationPageComponent', () => {
|
||||
let component: ExternalLoginEmailConfirmationPageComponent;
|
||||
let fixture: ComponentFixture<ExternalLoginEmailConfirmationPageComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [
|
||||
TranslateModule.forRoot({
|
||||
loader: {
|
||||
provide: TranslateLoader,
|
||||
useClass: TranslateLoaderMock,
|
||||
},
|
||||
}),
|
||||
ExternalLoginEmailConfirmationPageComponent,
|
||||
ConfirmationSentComponent,
|
||||
],
|
||||
})
|
||||
.compileComponents();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(ExternalLoginEmailConfirmationPageComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should render ConfirmationSentComponent', () => {
|
||||
const compiled = fixture.nativeElement;
|
||||
expect(compiled.querySelector('ds-confirmation-sent')).toBeTruthy();
|
||||
});
|
||||
});
|
@@ -0,0 +1,12 @@
|
||||
import { Component } from '@angular/core';
|
||||
|
||||
import { ConfirmationSentComponent } from '../external-log-in/email-confirmation/confirmation-sent/confirmation-sent.component';
|
||||
|
||||
@Component({
|
||||
templateUrl: './external-login-email-confirmation-page.component.html',
|
||||
styleUrls: ['./external-login-email-confirmation-page.component.scss'],
|
||||
standalone: true,
|
||||
imports: [ConfirmationSentComponent],
|
||||
})
|
||||
export class ExternalLoginEmailConfirmationPageComponent {
|
||||
}
|
@@ -0,0 +1,16 @@
|
||||
<div class="container">
|
||||
@if (registrationData$ | async; as registrationData) {
|
||||
<ds-external-log-in
|
||||
[authMethods]="authMethods"
|
||||
[registrationData]="registrationData"
|
||||
[token]="token"
|
||||
></ds-external-log-in>
|
||||
}
|
||||
|
||||
@if (hasErrors) {
|
||||
<ds-alert
|
||||
[type]="AlertTypeEnum.Error"
|
||||
[content]="'external-login.error.notification' | translate"
|
||||
></ds-alert>
|
||||
}
|
||||
</div>
|
@@ -0,0 +1,91 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
ComponentFixture,
|
||||
TestBed,
|
||||
} from '@angular/core/testing';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import {
|
||||
TranslateLoader,
|
||||
TranslateModule,
|
||||
} from '@ngx-translate/core';
|
||||
import { of } from 'rxjs';
|
||||
|
||||
import { Registration } from '../core/shared/registration.model';
|
||||
import { ExternalLogInComponent } from '../external-log-in/external-log-in/external-log-in.component';
|
||||
import { TranslateLoaderMock } from '../shared/mocks/translate-loader.mock';
|
||||
import { ExternalLoginPageComponent } from './external-login-page.component';
|
||||
|
||||
describe('ExternalLoginPageComponent', () => {
|
||||
let component: ExternalLoginPageComponent;
|
||||
let fixture: ComponentFixture<ExternalLoginPageComponent>;
|
||||
|
||||
const registrationDataMock = {
|
||||
registrationType: 'orcid',
|
||||
email: 'test@test.com',
|
||||
netId: '0000-0000-0000-0000',
|
||||
user: 'a44d8c9e-9b1f-4e7f-9b1a-5c9d8a0b1f1a',
|
||||
registrationMetadata: {
|
||||
'email': [{ value: 'test@test.com' }],
|
||||
'eperson.lastname': [{ value: 'Doe' }],
|
||||
'eperson.firstname': [{ value: 'John' }],
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
providers: [
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: {
|
||||
snapshot: {
|
||||
params: {
|
||||
token: '1234567890',
|
||||
},
|
||||
},
|
||||
data: of(registrationDataMock),
|
||||
},
|
||||
},
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
ExternalLoginPageComponent,
|
||||
TranslateModule.forRoot({
|
||||
loader: {
|
||||
provide: TranslateLoader,
|
||||
useClass: TranslateLoaderMock,
|
||||
},
|
||||
}),
|
||||
],
|
||||
})
|
||||
.overrideComponent(ExternalLoginPageComponent, {
|
||||
remove: {
|
||||
imports: [ExternalLogInComponent],
|
||||
},
|
||||
})
|
||||
.compileComponents();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(ExternalLoginPageComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should set the token from the query params', () => {
|
||||
expect(component.token).toEqual('1234567890');
|
||||
});
|
||||
|
||||
it('should display the DsExternalLogIn component when there are no errors', () => {
|
||||
const registrationData = Object.assign(new Registration(), registrationDataMock);
|
||||
component.registrationData$ = of(registrationData);
|
||||
component.token = '1234567890';
|
||||
component.hasErrors = false;
|
||||
fixture.detectChanges();
|
||||
const dsExternalLogInComponent = fixture.nativeElement.querySelector('ds-external-log-in');
|
||||
expect(dsExternalLogInComponent).toBeTruthy();
|
||||
});
|
||||
});
|
77
src/app/external-login-page/external-login-page.component.ts
Normal file
77
src/app/external-login-page/external-login-page.component.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { AsyncPipe } from '@angular/common';
|
||||
import {
|
||||
Component,
|
||||
OnInit,
|
||||
} from '@angular/core';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import {
|
||||
first,
|
||||
map,
|
||||
Observable,
|
||||
tap,
|
||||
} from 'rxjs';
|
||||
|
||||
import { AuthMethodType } from '../core/auth/models/auth.method-type';
|
||||
import { RemoteData } from '../core/data/remote-data';
|
||||
import { Registration } from '../core/shared/registration.model';
|
||||
import { ExternalLogInComponent } from '../external-log-in/external-log-in/external-log-in.component';
|
||||
import { AlertComponent } from '../shared/alert/alert.component';
|
||||
import { AlertType } from '../shared/alert/alert-type';
|
||||
import { hasNoValue } from '../shared/empty.util';
|
||||
import { AuthMethodTypeComponent } from '../shared/log-in/methods/auth-methods.type';
|
||||
import { AUTH_METHOD_FOR_DECORATOR_MAP } from '../shared/log-in/methods/log-in.methods-decorator';
|
||||
|
||||
@Component({
|
||||
templateUrl: './external-login-page.component.html',
|
||||
styleUrls: ['./external-login-page.component.scss'],
|
||||
imports: [
|
||||
TranslateModule,
|
||||
AsyncPipe,
|
||||
ExternalLogInComponent,
|
||||
AlertComponent,
|
||||
],
|
||||
standalone: true,
|
||||
})
|
||||
/**
|
||||
* This component is a wrapper of the external-login component that loads up the RegistrationData.
|
||||
*/
|
||||
export class ExternalLoginPageComponent implements OnInit {
|
||||
/**
|
||||
* The token used to get the registration data,
|
||||
* retrieved from the url.
|
||||
* @memberof ExternalLoginPageComponent
|
||||
*/
|
||||
public token: string;
|
||||
/**
|
||||
* The registration data of the user.
|
||||
*/
|
||||
public registrationData$: Observable<Registration>;
|
||||
/**
|
||||
* The type of alert to show.
|
||||
*/
|
||||
public AlertTypeEnum = AlertType;
|
||||
/**
|
||||
* Whether the component has errors.
|
||||
*/
|
||||
public hasErrors = false;
|
||||
|
||||
public authMethods: Map<AuthMethodType, AuthMethodTypeComponent>;
|
||||
|
||||
constructor(
|
||||
private arouter: ActivatedRoute,
|
||||
) {
|
||||
this.token = this.arouter.snapshot.params.token;
|
||||
this.hasErrors = hasNoValue(this.arouter.snapshot.params.token);
|
||||
this.authMethods = AUTH_METHOD_FOR_DECORATOR_MAP;
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.registrationData$ =
|
||||
this.arouter.data.pipe(
|
||||
first(),
|
||||
tap((data) => this.hasErrors = (data.registrationData as RemoteData<Registration>).hasFailed),
|
||||
map((data) => (data.registrationData as RemoteData<Registration>).payload),
|
||||
);
|
||||
}
|
||||
}
|
15
src/app/external-login-page/external-login-routes.ts
Normal file
15
src/app/external-login-page/external-login-routes.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Route } from '@angular/router';
|
||||
|
||||
import { registrationTokenGuard } from '../external-log-in/guards/registration-token-guard';
|
||||
import { RegistrationDataResolver } from '../external-log-in/resolvers/registration-data.resolver';
|
||||
import { ThemedExternalLoginPageComponent } from './themed-external-login-page.component';
|
||||
|
||||
export const ROUTES: Route[] = [
|
||||
{
|
||||
path: '',
|
||||
pathMatch: 'full',
|
||||
component: ThemedExternalLoginPageComponent,
|
||||
canActivate: [registrationTokenGuard],
|
||||
resolve: { registrationData: RegistrationDataResolver },
|
||||
},
|
||||
];
|
@@ -0,0 +1,28 @@
|
||||
import { Component } from '@angular/core';
|
||||
|
||||
import { ThemedComponent } from '../shared/theme-support/themed.component';
|
||||
import { ExternalLoginPageComponent } from './external-login-page.component';
|
||||
|
||||
/**
|
||||
* Themed wrapper for ExternalLoginPageComponent
|
||||
*/
|
||||
@Component({
|
||||
selector: 'ds-external-login-page',
|
||||
styleUrls: [],
|
||||
templateUrl: './../shared/theme-support/themed.component.html',
|
||||
standalone: true,
|
||||
imports: [ExternalLoginPageComponent],
|
||||
})
|
||||
export class ThemedExternalLoginPageComponent extends ThemedComponent<ExternalLoginPageComponent> {
|
||||
protected getComponentName(): string {
|
||||
return 'ExternalLoginPageComponent';
|
||||
}
|
||||
|
||||
protected importThemedComponent(themeName: string): Promise<any> {
|
||||
return import(`../../themes/${themeName}/app/external-login-page/external-login-page.component`);
|
||||
}
|
||||
|
||||
protected importUnthemedComponent(): Promise<any> {
|
||||
return import(`./external-login-page.component`);
|
||||
}
|
||||
}
|
@@ -0,0 +1,15 @@
|
||||
import { Route } from '@angular/router';
|
||||
|
||||
import { RegistrationDataResolver } from '../external-log-in/resolvers/registration-data.resolver';
|
||||
import { ReviewAccountGuard } from './helpers/review-account.guard';
|
||||
import { ThemedExternalLoginReviewAccountInfoPageComponent } from './themed-external-login-review-account-info-page.component';
|
||||
|
||||
export const ROUTES: Route[] = [
|
||||
{
|
||||
path: '',
|
||||
pathMatch: 'full',
|
||||
component: ThemedExternalLoginReviewAccountInfoPageComponent,
|
||||
canActivate: [ReviewAccountGuard],
|
||||
resolve: { registrationData: RegistrationDataResolver },
|
||||
},
|
||||
];
|
@@ -0,0 +1,14 @@
|
||||
<div class="container">
|
||||
@if (registrationData$ | async; as registrationData) {
|
||||
<ds-review-account-info
|
||||
[registrationToken]="token"
|
||||
[registrationData]="registrationData"
|
||||
></ds-review-account-info>
|
||||
}
|
||||
@if (hasErrors) {
|
||||
<ds-alert
|
||||
[type]="AlertTypeEnum.Error"
|
||||
[content]="'review-account-info.alert.error.content'"
|
||||
></ds-alert>
|
||||
}
|
||||
</div>
|
@@ -0,0 +1,76 @@
|
||||
import {
|
||||
ComponentFixture,
|
||||
TestBed,
|
||||
} from '@angular/core/testing';
|
||||
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { of } from 'rxjs';
|
||||
|
||||
import { mockRegistrationDataModel } from '../external-log-in/models/registration-data.mock.model';
|
||||
import { ExternalLoginReviewAccountInfoPageComponent } from './external-login-review-account-info-page.component';
|
||||
import { ReviewAccountInfoComponent } from './review-account-info/review-account-info.component';
|
||||
|
||||
describe('ExternalLoginReviewAccountInfoPageComponent', () => {
|
||||
let component: ExternalLoginReviewAccountInfoPageComponent;
|
||||
let fixture: ComponentFixture<ExternalLoginReviewAccountInfoPageComponent>;
|
||||
|
||||
const mockActivatedRoute = {
|
||||
snapshot: {
|
||||
params: {
|
||||
token: '1234567890',
|
||||
},
|
||||
},
|
||||
data: of({
|
||||
registrationData: mockRegistrationDataModel,
|
||||
}),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
providers: [
|
||||
{ provide: ActivatedRoute, useValue: mockActivatedRoute },
|
||||
],
|
||||
imports: [
|
||||
ExternalLoginReviewAccountInfoPageComponent,
|
||||
BrowserAnimationsModule,
|
||||
TranslateModule.forRoot({}),
|
||||
],
|
||||
})
|
||||
.overrideComponent(ExternalLoginReviewAccountInfoPageComponent, {
|
||||
remove: {
|
||||
imports: [ReviewAccountInfoComponent],
|
||||
},
|
||||
})
|
||||
.compileComponents();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(ExternalLoginReviewAccountInfoPageComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should set the token from the query params', () => {
|
||||
expect(component.token).toEqual('1234567890');
|
||||
});
|
||||
|
||||
it('should display review account info component when there are no errors', () => {
|
||||
component.hasErrors = false;
|
||||
component.registrationData$ = of(mockRegistrationDataModel);
|
||||
fixture.detectChanges();
|
||||
const reviewAccountInfoComponent = fixture.nativeElement.querySelector('ds-review-account-info');
|
||||
expect(reviewAccountInfoComponent).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should display error alert when there are errors', () => {
|
||||
component.hasErrors = true;
|
||||
fixture.detectChanges();
|
||||
const errorAlertComponent = fixture.nativeElement.querySelector('ds-alert');
|
||||
expect(errorAlertComponent).toBeTruthy();
|
||||
});
|
||||
});
|
@@ -0,0 +1,68 @@
|
||||
import { AsyncPipe } from '@angular/common';
|
||||
import {
|
||||
Component,
|
||||
OnInit,
|
||||
} from '@angular/core';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import {
|
||||
first,
|
||||
map,
|
||||
Observable,
|
||||
tap,
|
||||
} from 'rxjs';
|
||||
|
||||
import { RemoteData } from '../core/data/remote-data';
|
||||
import { Registration } from '../core/shared/registration.model';
|
||||
import { AlertComponent } from '../shared/alert/alert.component';
|
||||
import { AlertType } from '../shared/alert/alert-type';
|
||||
import { hasNoValue } from '../shared/empty.util';
|
||||
import { ReviewAccountInfoComponent } from './review-account-info/review-account-info.component';
|
||||
|
||||
@Component({
|
||||
templateUrl: './external-login-review-account-info-page.component.html',
|
||||
styleUrls: ['./external-login-review-account-info-page.component.scss'],
|
||||
imports: [
|
||||
ReviewAccountInfoComponent,
|
||||
AsyncPipe,
|
||||
AlertComponent,
|
||||
],
|
||||
standalone: true,
|
||||
})
|
||||
/**
|
||||
* This component is a wrapper for review-account-info component responsible to provide RegistrationData.
|
||||
*/
|
||||
export class ExternalLoginReviewAccountInfoPageComponent implements OnInit {
|
||||
/**
|
||||
* The token used to get the registration data
|
||||
*/
|
||||
public token: string;
|
||||
|
||||
/**
|
||||
* The type of alert to show
|
||||
*/
|
||||
public AlertTypeEnum = AlertType;
|
||||
|
||||
/**
|
||||
* The registration data of the user
|
||||
*/
|
||||
public registrationData$: Observable<Registration>;
|
||||
/**
|
||||
* Whether the component has errors
|
||||
*/
|
||||
public hasErrors = false;
|
||||
|
||||
constructor(
|
||||
private arouter: ActivatedRoute,
|
||||
) {
|
||||
this.token = this.arouter.snapshot.params.token;
|
||||
this.hasErrors = hasNoValue(this.arouter.snapshot.params.token);
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.registrationData$ = this.arouter.data.pipe(
|
||||
first(),
|
||||
tap((data) => this.hasErrors = (data.registrationData as RemoteData<Registration>).hasFailed),
|
||||
map((data) => (data.registrationData as RemoteData<Registration>).payload));
|
||||
}
|
||||
}
|
||||
|
@@ -0,0 +1,26 @@
|
||||
import {
|
||||
Pipe,
|
||||
PipeTransform,
|
||||
} from '@angular/core';
|
||||
|
||||
@Pipe({
|
||||
name: 'dsCompareValues',
|
||||
standalone: true,
|
||||
})
|
||||
export class CompareValuesPipe implements PipeTransform {
|
||||
|
||||
/**
|
||||
* Returns a string with a checkmark if the received value is equal to the current value,
|
||||
* or the current value if they are not equal.
|
||||
* @param receivedValue the value received from the registration data
|
||||
* @param currentValue the value from the current user
|
||||
* @returns the value to be displayed in the template
|
||||
*/
|
||||
transform(receivedValue: string, currentValue: string): string {
|
||||
if (receivedValue === currentValue) {
|
||||
return '<i class="fa fa-check-circle text-success fa-xl" aria-hidden="true"></i>';
|
||||
} else {
|
||||
return currentValue;
|
||||
}
|
||||
}
|
||||
}
|
@@ -0,0 +1,121 @@
|
||||
import {
|
||||
fakeAsync,
|
||||
TestBed,
|
||||
tick,
|
||||
} from '@angular/core/testing';
|
||||
import {
|
||||
ActivatedRoute,
|
||||
convertToParamMap,
|
||||
Params,
|
||||
Router,
|
||||
RouterStateSnapshot,
|
||||
} from '@angular/router';
|
||||
import {
|
||||
Observable,
|
||||
of as observableOf,
|
||||
of,
|
||||
} from 'rxjs';
|
||||
|
||||
import { AuthService } from '../../core/auth/auth.service';
|
||||
import { AuthRegistrationType } from '../../core/auth/models/auth.registration-type';
|
||||
import { EpersonRegistrationService } from '../../core/data/eperson-registration.service';
|
||||
import { Registration } from '../../core/shared/registration.model';
|
||||
import { RouterMock } from '../../shared/mocks/router.mock';
|
||||
import {
|
||||
createFailedRemoteDataObject$,
|
||||
createSuccessfulRemoteDataObject$,
|
||||
} from '../../shared/remote-data.utils';
|
||||
import { ReviewAccountGuard } from './review-account.guard';
|
||||
|
||||
describe('ReviewAccountGuard', () => {
|
||||
let epersonRegistrationService: any;
|
||||
let authService: any;
|
||||
let router: any;
|
||||
|
||||
const registrationMock = Object.assign(new Registration(), {
|
||||
email: 'test@email.org',
|
||||
registrationType: AuthRegistrationType.Validation,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
const paramObject: Params = { token: '1234' };
|
||||
epersonRegistrationService = jasmine.createSpyObj('epersonRegistrationService', {
|
||||
searchByTokenAndHandleError: createSuccessfulRemoteDataObject$(registrationMock),
|
||||
});
|
||||
authService = {
|
||||
isAuthenticated: () => observableOf(true),
|
||||
} as any;
|
||||
router = new RouterMock();
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
{ provide: Router, useValue: router },
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: {
|
||||
queryParamMap: observableOf(convertToParamMap(paramObject)),
|
||||
snapshot: {
|
||||
params: {
|
||||
token: '1234',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{ provide: EpersonRegistrationService, useValue: epersonRegistrationService },
|
||||
{ provide: AuthService, useValue: authService },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
it('should return true when registration type is validation', fakeAsync(() => {
|
||||
const state = {} as RouterStateSnapshot;
|
||||
const activatedRoute = TestBed.inject(ActivatedRoute);
|
||||
|
||||
const result$ = TestBed.runInInjectionContext(()=> {
|
||||
return ReviewAccountGuard(activatedRoute.snapshot, state) as Observable<boolean>;
|
||||
});
|
||||
|
||||
let output = null;
|
||||
result$.subscribe((result) => (output = result));
|
||||
tick(100);
|
||||
expect(output).toBeTrue();
|
||||
}));
|
||||
|
||||
|
||||
it('should navigate to 404 if the registration search fails', fakeAsync(() => {
|
||||
const state = {} as RouterStateSnapshot;
|
||||
const activatedRoute = TestBed.inject(ActivatedRoute);
|
||||
epersonRegistrationService.searchByTokenAndHandleError.and.returnValue(createFailedRemoteDataObject$());
|
||||
|
||||
const result$ = TestBed.runInInjectionContext(() => {
|
||||
return ReviewAccountGuard(activatedRoute.snapshot, state) as Observable<boolean>;
|
||||
});
|
||||
|
||||
let output = null;
|
||||
result$.subscribe((result) => (output = result));
|
||||
tick(100);
|
||||
expect(output).toBeFalse();
|
||||
expect(router.navigate).toHaveBeenCalledWith(['/404']);
|
||||
}));
|
||||
|
||||
|
||||
|
||||
it('should navigate to 404 if the registration type is not validation and the user is not authenticated', fakeAsync(() => {
|
||||
registrationMock.registrationType = AuthRegistrationType.Orcid;
|
||||
epersonRegistrationService.searchByTokenAndHandleError.and.returnValue(createSuccessfulRemoteDataObject$(registrationMock));
|
||||
spyOn(authService, 'isAuthenticated').and.returnValue(of(false));
|
||||
const activatedRoute = TestBed.inject(ActivatedRoute);
|
||||
|
||||
const result$ = TestBed.runInInjectionContext(() => {
|
||||
return ReviewAccountGuard(activatedRoute.snapshot, {} as RouterStateSnapshot) as Observable<boolean>;
|
||||
});
|
||||
|
||||
let output = null;
|
||||
result$.subscribe((result) => (output = result));
|
||||
tick(100);
|
||||
expect(output).toBeFalse();
|
||||
expect(router.navigate).toHaveBeenCalledWith(['/404']);
|
||||
}));
|
||||
});
|
||||
|
@@ -0,0 +1,69 @@
|
||||
import { inject } from '@angular/core';
|
||||
import {
|
||||
ActivatedRouteSnapshot,
|
||||
CanActivateFn,
|
||||
Router,
|
||||
RouterStateSnapshot,
|
||||
} from '@angular/router';
|
||||
import {
|
||||
catchError,
|
||||
mergeMap,
|
||||
Observable,
|
||||
of,
|
||||
tap,
|
||||
} from 'rxjs';
|
||||
|
||||
import { AuthService } from '../../core/auth/auth.service';
|
||||
import { AuthRegistrationType } from '../../core/auth/models/auth.registration-type';
|
||||
import { EpersonRegistrationService } from '../../core/data/eperson-registration.service';
|
||||
import { RemoteData } from '../../core/data/remote-data';
|
||||
import { getFirstCompletedRemoteData } from '../../core/shared/operators';
|
||||
import { Registration } from '../../core/shared/registration.model';
|
||||
import { hasValue } from '../../shared/empty.util';
|
||||
|
||||
/**
|
||||
* Determines if a user can activate a route based on the registration token.z
|
||||
* @param route - The activated route snapshot.
|
||||
* @param state - The router state snapshot.
|
||||
* @returns A value indicating if the user can activate the route.
|
||||
*/
|
||||
export const ReviewAccountGuard: CanActivateFn = (
|
||||
route: ActivatedRouteSnapshot,
|
||||
state: RouterStateSnapshot,
|
||||
): Observable<boolean> => {
|
||||
const authService = inject(AuthService);
|
||||
const router = inject(Router);
|
||||
const epersonRegistrationService = inject(EpersonRegistrationService);
|
||||
if (route.params.token) {
|
||||
return epersonRegistrationService
|
||||
.searchByTokenAndHandleError(route.params.token)
|
||||
.pipe(
|
||||
getFirstCompletedRemoteData(),
|
||||
mergeMap(
|
||||
(data: RemoteData<Registration>) => {
|
||||
if (data.hasSucceeded && hasValue(data.payload)) {
|
||||
// is the registration type validation (account valid)
|
||||
if (hasValue(data.payload.registrationType) && data.payload.registrationType.includes(AuthRegistrationType.Validation)) {
|
||||
return of(true);
|
||||
} else {
|
||||
return authService.isAuthenticated();
|
||||
}
|
||||
}
|
||||
return of(false);
|
||||
},
|
||||
),
|
||||
tap((isValid: boolean) => {
|
||||
if (!isValid) {
|
||||
router.navigate(['/404']);
|
||||
}
|
||||
}),
|
||||
catchError(() => {
|
||||
router.navigate(['/404']);
|
||||
return of(false);
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
router.navigate(['/404']);
|
||||
return of(false);
|
||||
}
|
||||
};
|
@@ -0,0 +1,61 @@
|
||||
<h2> {{'external-login-validation.review-account-info.header' | translate}}</h2>
|
||||
|
||||
<ds-alert [type]="AlertTypeEnum.Info" [content]="'external-login-validation.review-account-info.info' | translate"></ds-alert>
|
||||
<div class="justify-content-center table-responsive">
|
||||
<table class="table table-lg">
|
||||
<thead class="thead-light">
|
||||
<tr>
|
||||
<th scope="col">
|
||||
<span class="h5 font-weight-bold">{{ 'external-login-validation.review-account-info.table.header.information' | translate }}</span>
|
||||
</th>
|
||||
<th scope="col">
|
||||
<span class="h5 font-weight-bold">{{'external-login-validation.review-account-info.table.header.received-value' | translate }}</span>
|
||||
</th>
|
||||
<th scope="col">
|
||||
<span class="h5 font-weight-bold">{{'external-login-validation.review-account-info.table.header.current-value' | translate }}</span>
|
||||
</th>
|
||||
<th scope="col"><span class="h5 font-weight-bold">{{'external-login-validation.review-account-info.table.header.action' | translate }}</span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<th scope="row" class="text-uppercase">{{ registrationData.registrationType }}</th>
|
||||
<td>{{ registrationData.netId }}</td>
|
||||
<td>
|
||||
<span>
|
||||
{{ 'external-login-validation.review-account-info.table.row.not-applicable' | translate }}
|
||||
</span>
|
||||
</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
@for (data of dataToCompare; track data) {
|
||||
<tr>
|
||||
<th scope="row">{{ data.label | titlecase }}</th>
|
||||
<td>{{ data.receivedValue }}</td>
|
||||
<td>
|
||||
<span
|
||||
[innerHTML]="
|
||||
data.receivedValue | dsCompareValues : data.currentValue
|
||||
"
|
||||
>
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
@if ((data.receivedValue !== data.currentValue) && data.currentValue) {
|
||||
<ui-switch
|
||||
[checkedLabel]="'on-label' | translate"
|
||||
[uncheckedLabel]="'off-label' | translate"
|
||||
[checked]="data.overrideValue"
|
||||
(change)="onOverrideChange($event, data.identifier)"
|
||||
></ui-switch>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table> <div class="d-flex justify-content-end">
|
||||
<button class="btn btn-primary" (click)="onSave()">
|
||||
{{'confirmation-modal.review-account-info.save' | translate}}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
@@ -0,0 +1,13 @@
|
||||
:host {
|
||||
table {
|
||||
tbody {
|
||||
background-color: #f7f8f9;
|
||||
}
|
||||
|
||||
td,
|
||||
th {
|
||||
height: 60px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
}
|
@@ -0,0 +1,245 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { EventEmitter } from '@angular/core';
|
||||
import {
|
||||
ComponentFixture,
|
||||
fakeAsync,
|
||||
TestBed,
|
||||
tick,
|
||||
} from '@angular/core/testing';
|
||||
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
|
||||
import { Router } from '@angular/router';
|
||||
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
|
||||
import {
|
||||
TranslateLoader,
|
||||
TranslateModule,
|
||||
TranslateService,
|
||||
} from '@ngx-translate/core';
|
||||
import {
|
||||
Observable,
|
||||
of,
|
||||
Subscription,
|
||||
} from 'rxjs';
|
||||
|
||||
import { AuthService } from '../../core/auth/auth.service';
|
||||
import { RemoteData } from '../../core/data/remote-data';
|
||||
import { EPersonDataService } from '../../core/eperson/eperson-data.service';
|
||||
import { EPerson } from '../../core/eperson/models/eperson.model';
|
||||
import { HardRedirectService } from '../../core/services/hard-redirect.service';
|
||||
import { NativeWindowService } from '../../core/services/window.service';
|
||||
import { Registration } from '../../core/shared/registration.model';
|
||||
import { ExternalLoginService } from '../../external-log-in/services/external-login.service';
|
||||
import { AuthServiceMock } from '../../shared/mocks/auth.service.mock';
|
||||
import { NativeWindowMockFactory } from '../../shared/mocks/mock-native-window-ref';
|
||||
import { RouterMock } from '../../shared/mocks/router.mock';
|
||||
import { TranslateLoaderMock } from '../../shared/mocks/translate-loader.mock';
|
||||
import { NotificationsService } from '../../shared/notifications/notifications.service';
|
||||
import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
|
||||
import { EPersonMock } from '../../shared/testing/eperson.mock';
|
||||
import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub';
|
||||
import { CompareValuesPipe } from '../helpers/compare-values.pipe';
|
||||
import { ReviewAccountInfoComponent } from './review-account-info.component';
|
||||
|
||||
describe('ReviewAccountInfoComponent', () => {
|
||||
let component: ReviewAccountInfoComponent;
|
||||
let componentAsAny: any;
|
||||
let fixture: ComponentFixture<ReviewAccountInfoComponent>;
|
||||
let ePersonDataServiceStub: any;
|
||||
let router: any;
|
||||
let notificationsService: any;
|
||||
let externalLoginServiceStub: any;
|
||||
let hardRedirectService: HardRedirectService;
|
||||
let authService: any;
|
||||
|
||||
const translateServiceStub = {
|
||||
get: () => of('test-message'),
|
||||
onLangChange: new EventEmitter(),
|
||||
onTranslationChange: new EventEmitter(),
|
||||
onDefaultLangChange: new EventEmitter(),
|
||||
};
|
||||
const mockEPerson = EPersonMock;
|
||||
const modalStub = {
|
||||
open: () => ({ componentInstance: { response: of(true) } }),
|
||||
close: () => null,
|
||||
dismiss: () => null,
|
||||
};
|
||||
const registrationDataMock = {
|
||||
registrationType: 'orcid',
|
||||
email: 'test@test.com',
|
||||
netId: '0000-0000-0000-0000',
|
||||
user: 'a44d8c9e-9b1f-4e7f-9b1a-5c9d8a0b1f1a',
|
||||
registrationMetadata: {
|
||||
'email': [{ value: 'test@test.com' }],
|
||||
'eperson.lastname': [{ value: 'Doe' }],
|
||||
'eperson.firstname': [{ value: 'John' }],
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
ePersonDataServiceStub = {
|
||||
findById(uuid: string): Observable<RemoteData<EPerson>> {
|
||||
return createSuccessfulRemoteDataObject$(mockEPerson);
|
||||
},
|
||||
mergeEPersonDataWithToken(
|
||||
token: string,
|
||||
metadata?: string,
|
||||
): Observable<RemoteData<EPerson>> {
|
||||
return createSuccessfulRemoteDataObject$(mockEPerson);
|
||||
},
|
||||
};
|
||||
router = new RouterMock();
|
||||
notificationsService = new NotificationsServiceStub();
|
||||
externalLoginServiceStub = {
|
||||
getExternalAuthLocation: () => of('https://orcid.org/oauth/authorize'),
|
||||
};
|
||||
hardRedirectService = jasmine.createSpyObj('HardRedirectService', {
|
||||
redirect: (url: string) => null,
|
||||
});
|
||||
authService = new AuthServiceMock();
|
||||
await TestBed.configureTestingModule({
|
||||
providers: [
|
||||
{ provide: NativeWindowService, useFactory: NativeWindowMockFactory },
|
||||
{ provide: EPersonDataService, useValue: ePersonDataServiceStub },
|
||||
{ provide: NgbModal, useValue: modalStub },
|
||||
{
|
||||
provide: NotificationsService,
|
||||
useValue: notificationsService,
|
||||
},
|
||||
{ provide: TranslateService, useValue: translateServiceStub },
|
||||
{ provide: Router, useValue: router },
|
||||
{ provide: AuthService, useValue: authService },
|
||||
{ provide: ExternalLoginService, useValue: externalLoginServiceStub },
|
||||
{ provide: HardRedirectService, useValue: hardRedirectService },
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
BrowserAnimationsModule,
|
||||
ReviewAccountInfoComponent,
|
||||
CompareValuesPipe,
|
||||
TranslateModule.forRoot({
|
||||
loader: {
|
||||
provide: TranslateLoader,
|
||||
useClass: TranslateLoaderMock,
|
||||
},
|
||||
}),
|
||||
],
|
||||
}).compileComponents();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(ReviewAccountInfoComponent);
|
||||
component = fixture.componentInstance;
|
||||
componentAsAny = component;
|
||||
component.registrationData = Object.assign(
|
||||
new Registration(),
|
||||
registrationDataMock,
|
||||
);
|
||||
component.registrationToken = 'test-token';
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should prepare data to compare', () => {
|
||||
component.ngOnInit();
|
||||
const dataToCompare = component.dataToCompare;
|
||||
expect(dataToCompare.length).toBe(3);
|
||||
expect(dataToCompare[0].label).toBe('email');
|
||||
expect(dataToCompare[1].label).toBe('lastname');
|
||||
expect(dataToCompare[2].label).toBe('firstname');
|
||||
expect(dataToCompare[0].overrideValue).toBe(false);
|
||||
expect(dataToCompare[0].receivedValue).toBe('test@test.com');
|
||||
});
|
||||
|
||||
it('should update dataToCompare when overrideValue is changed', () => {
|
||||
component.onOverrideChange(true, 'email');
|
||||
expect(component.dataToCompare[0].overrideValue).toBe(true);
|
||||
});
|
||||
|
||||
it('should open a confirmation modal on onSave and confirm', fakeAsync(() => {
|
||||
spyOn(modalStub, 'open').and.returnValue({
|
||||
componentInstance: { response: of(true) },
|
||||
});
|
||||
spyOn(component, 'mergeEPersonDataWithToken');
|
||||
component.onSave();
|
||||
tick();
|
||||
expect(modalStub.open).toHaveBeenCalled();
|
||||
expect(component.mergeEPersonDataWithToken).toHaveBeenCalled();
|
||||
}));
|
||||
|
||||
it('should open a confirmation modal on onSave and cancel', fakeAsync(() => {
|
||||
spyOn(modalStub, 'open').and.returnValue({
|
||||
componentInstance: { response: of(false) },
|
||||
});
|
||||
spyOn(component, 'mergeEPersonDataWithToken');
|
||||
component.onSave();
|
||||
tick();
|
||||
expect(modalStub.open).toHaveBeenCalled();
|
||||
expect(component.mergeEPersonDataWithToken).not.toHaveBeenCalled();
|
||||
}));
|
||||
|
||||
it('should merge EPerson data with token when overrideValue is true', fakeAsync(() => {
|
||||
component.dataToCompare[0].overrideValue = true;
|
||||
spyOn(ePersonDataServiceStub, 'mergeEPersonDataWithToken').and.returnValue(
|
||||
of({ hasSucceeded: true }),
|
||||
);
|
||||
component.mergeEPersonDataWithToken(registrationDataMock.user, registrationDataMock.registrationType);
|
||||
tick();
|
||||
expect(ePersonDataServiceStub.mergeEPersonDataWithToken).toHaveBeenCalledTimes(1);
|
||||
expect(router.navigate).toHaveBeenCalledWith(['/profile']);
|
||||
}));
|
||||
|
||||
it('should display registration data', () => {
|
||||
const registrationTypeElement: HTMLElement = fixture.nativeElement.querySelector('tbody tr:first-child th');
|
||||
const netIdElement: HTMLElement = fixture.nativeElement.querySelector('tbody tr:first-child td');
|
||||
|
||||
expect(registrationTypeElement.textContent.trim()).toBe(registrationDataMock.registrationType);
|
||||
expect(netIdElement.textContent.trim()).toBe(registrationDataMock.netId);
|
||||
});
|
||||
|
||||
it('should display dataToCompare rows with translated labels and values', () => {
|
||||
const dataRows: NodeListOf<HTMLElement> = fixture.nativeElement.querySelectorAll('tbody tr:not(:first-child)');
|
||||
// Assuming there are 3 dataToCompare rows based on the registrationDataMock
|
||||
expect(dataRows.length).toBe(3);
|
||||
// Assuming the first row is the email row abd the second row is the lastname row
|
||||
const firstDataRow = dataRows[1];
|
||||
const firstDataLabel: HTMLElement = firstDataRow.querySelector('th');
|
||||
const firstDataReceivedValue: HTMLElement = firstDataRow.querySelectorAll('td')[0];
|
||||
const firstDataOverrideSwitch: HTMLElement = firstDataRow.querySelector('ui-switch');
|
||||
expect(firstDataLabel.textContent.trim()).toBe('Lastname');
|
||||
expect(firstDataReceivedValue.textContent.trim()).toBe('Doe');
|
||||
expect(firstDataOverrideSwitch).toBeNull();
|
||||
});
|
||||
|
||||
it('should trigger onSave() when the button is clicked', () => {
|
||||
spyOn(component, 'onSave');
|
||||
const saveButton: HTMLButtonElement = fixture.nativeElement.querySelector('button.btn-primary');
|
||||
saveButton.click();
|
||||
expect(component.onSave).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should unsubscribe from subscriptions when ngOnDestroy is called', () => {
|
||||
const subscription1 = jasmine.createSpyObj<Subscription>('Subscription', [
|
||||
'unsubscribe',
|
||||
]);
|
||||
const subscription2 = jasmine.createSpyObj<Subscription>('Subscription', [
|
||||
'unsubscribe',
|
||||
]);
|
||||
component.subs = [subscription1, subscription2];
|
||||
component.ngOnDestroy();
|
||||
expect(subscription1.unsubscribe).toHaveBeenCalled();
|
||||
expect(subscription2.unsubscribe).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle authenticated user', () => {
|
||||
const override$ = createSuccessfulRemoteDataObject$(new EPerson());
|
||||
component.handleAuthenticatedUser(override$);
|
||||
expect(componentAsAny.notificationService.success).toHaveBeenCalled();
|
||||
expect(router.navigate).toHaveBeenCalledWith(['/profile']);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fixture.destroy();
|
||||
});
|
||||
});
|
@@ -0,0 +1,317 @@
|
||||
import { TitleCasePipe } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
Inject,
|
||||
Input,
|
||||
OnDestroy,
|
||||
OnInit,
|
||||
} from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
|
||||
import {
|
||||
TranslateModule,
|
||||
TranslateService,
|
||||
} from '@ngx-translate/core';
|
||||
import { UiSwitchModule } from 'ngx-ui-switch';
|
||||
import {
|
||||
combineLatest,
|
||||
filter,
|
||||
from,
|
||||
map,
|
||||
Observable,
|
||||
Subscription,
|
||||
switchMap,
|
||||
take,
|
||||
tap,
|
||||
} from 'rxjs';
|
||||
|
||||
import { AuthService } from '../../core/auth/auth.service';
|
||||
import { AuthRegistrationType } from '../../core/auth/models/auth.registration-type';
|
||||
import { RemoteData } from '../../core/data/remote-data';
|
||||
import { EPersonDataService } from '../../core/eperson/eperson-data.service';
|
||||
import { EPerson } from '../../core/eperson/models/eperson.model';
|
||||
import { HardRedirectService } from '../../core/services/hard-redirect.service';
|
||||
import {
|
||||
NativeWindowRef,
|
||||
NativeWindowService,
|
||||
} from '../../core/services/window.service';
|
||||
import { Registration } from '../../core/shared/registration.model';
|
||||
import { ExternalLoginService } from '../../external-log-in/services/external-login.service';
|
||||
import { AlertComponent } from '../../shared/alert/alert.component';
|
||||
import { AlertType } from '../../shared/alert/alert-type';
|
||||
import { ConfirmationModalComponent } from '../../shared/confirmation-modal/confirmation-modal.component';
|
||||
import { hasValue } from '../../shared/empty.util';
|
||||
import { NotificationsService } from '../../shared/notifications/notifications.service';
|
||||
import { CompareValuesPipe } from '../helpers/compare-values.pipe';
|
||||
|
||||
export interface ReviewAccountInfoData {
|
||||
label: string;
|
||||
currentValue: string;
|
||||
receivedValue: string;
|
||||
overrideValue: boolean;
|
||||
identifier: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'ds-review-account-info',
|
||||
templateUrl: './review-account-info.component.html',
|
||||
styleUrls: ['./review-account-info.component.scss'],
|
||||
imports: [
|
||||
CompareValuesPipe,
|
||||
TitleCasePipe,
|
||||
TranslateModule,
|
||||
AlertComponent,
|
||||
UiSwitchModule,
|
||||
],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: true,
|
||||
})
|
||||
/**
|
||||
* This component shows up the difference between the current account details and the one provided by the
|
||||
* Registration data.
|
||||
*/
|
||||
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;
|
||||
|
||||
/**
|
||||
* List of data to compare
|
||||
*/
|
||||
dataToCompare: ReviewAccountInfoData[] = [];
|
||||
/**
|
||||
* List of subscriptions
|
||||
*/
|
||||
subs: Subscription[] = [];
|
||||
|
||||
constructor(
|
||||
@Inject(NativeWindowService) protected _window: NativeWindowRef,
|
||||
private ePersonService: EPersonDataService,
|
||||
private modalService: NgbModal,
|
||||
private notificationService: NotificationsService,
|
||||
private translateService: TranslateService,
|
||||
private router: Router,
|
||||
private authService: AuthService,
|
||||
private externalLoginService: ExternalLoginService,
|
||||
private hardRedirectService: HardRedirectService,
|
||||
) { }
|
||||
|
||||
ngOnInit(): void {
|
||||
this.dataToCompare = this.prepareDataToCompare();
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the data to compare based on the metadata key and update the override value
|
||||
* @param value value of the override checkbox
|
||||
* @param identifier the metadata key
|
||||
*/
|
||||
public onOverrideChange(value: boolean, identifier: string) {
|
||||
this.dataToCompare.find(
|
||||
(data) => data.identifier === identifier,
|
||||
).overrideValue = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a confirmation modal to confirm the override of the data
|
||||
* If confirmed, merge the data from the registration token with the data from the eperson.
|
||||
* There are 2 cases:
|
||||
* -> If the user is authenticated, merge the data and redirect to profile page.
|
||||
* -> If the user is not authenticated, combine the override$, external auth location and redirect URL observables.
|
||||
*/
|
||||
public onSave() {
|
||||
const modalRef = this.modalService.open(ConfirmationModalComponent);
|
||||
modalRef.componentInstance.headerLabel =
|
||||
'confirmation-modal.review-account-info.header';
|
||||
modalRef.componentInstance.infoLabel =
|
||||
'confirmation-modal.review-account-info.info';
|
||||
modalRef.componentInstance.cancelLabel =
|
||||
'confirmation-modal.review-account-info.cancel';
|
||||
modalRef.componentInstance.confirmLabel =
|
||||
'confirmation-modal.review-account-info.confirm';
|
||||
modalRef.componentInstance.brandColor = 'primary';
|
||||
modalRef.componentInstance.confirmIcon = 'fa fa-check';
|
||||
|
||||
if (!this.registrationData.user) {
|
||||
this.subs.push(
|
||||
this.isAuthenticated()
|
||||
.pipe(
|
||||
filter((isAuthenticated) => isAuthenticated),
|
||||
switchMap(() => this.authService.getAuthenticatedUserFromStore()),
|
||||
filter((user) => hasValue(user)),
|
||||
map((user) => user.uuid),
|
||||
switchMap((userId) =>
|
||||
modalRef.componentInstance.response.pipe(
|
||||
tap((confirm: boolean) => {
|
||||
if (confirm) {
|
||||
this.mergeEPersonDataWithToken(userId, this.registrationData.registrationType);
|
||||
}
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
.subscribe(),
|
||||
);
|
||||
} else if (this.registrationData.user) {
|
||||
this.subs.push(
|
||||
modalRef.componentInstance.response
|
||||
.pipe(take(1))
|
||||
.subscribe((confirm: boolean) => {
|
||||
if (confirm && this.registrationData.user) {
|
||||
const registrationType = this.registrationData.registrationType.split(AuthRegistrationType.Validation)[1];
|
||||
this.mergeEPersonDataWithToken(this.registrationData.user, registrationType);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge the data from the registration token with the data from the eperson.
|
||||
* If any of the metadata is overridden, sent a merge request for each metadata to override.
|
||||
* If none of the metadata is overridden, sent a merge request with the registration token only.
|
||||
*/
|
||||
mergeEPersonDataWithToken(userId: string, registrationType: string) {
|
||||
let override$: Observable<RemoteData<EPerson>>;
|
||||
if (this.dataToCompare.some((d) => d.overrideValue)) {
|
||||
override$ = from(this.dataToCompare).pipe(
|
||||
filter((data: ReviewAccountInfoData) => data.overrideValue),
|
||||
switchMap((data: ReviewAccountInfoData) => {
|
||||
return this.ePersonService.mergeEPersonDataWithToken(
|
||||
userId,
|
||||
this.registrationToken,
|
||||
data.identifier,
|
||||
);
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
override$ = this.ePersonService.mergeEPersonDataWithToken(
|
||||
userId,
|
||||
this.registrationToken,
|
||||
);
|
||||
}
|
||||
if (this.registrationData.user && this.registrationData.registrationType.includes(AuthRegistrationType.Validation)) {
|
||||
this.handleUnauthenticatedUser(override$, registrationType);
|
||||
} else {
|
||||
this.handleAuthenticatedUser(override$);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the authenticated user by subscribing to the override$ observable and displaying a success or error notification based on the response.
|
||||
* If the response has succeeded, the user is redirected to the profile page.
|
||||
* @param override$ - The observable that emits the response containing the RemoteData<EPerson> object.
|
||||
*/
|
||||
handleAuthenticatedUser(override$: Observable<RemoteData<EPerson>>) {
|
||||
this.subs.push(
|
||||
override$.subscribe((response: RemoteData<EPerson>) => {
|
||||
if (response.hasSucceeded) {
|
||||
this.notificationService.success(
|
||||
this.translateService.get(
|
||||
'review-account-info.merge-data.notification.success',
|
||||
),
|
||||
);
|
||||
this.router.navigate(['/profile']);
|
||||
} else if (response.hasFailed) {
|
||||
this.notificationService.error(
|
||||
this.translateService.get(
|
||||
'review-account-info.merge-data.notification.error',
|
||||
),
|
||||
);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles unauthenticated user by combining the override$, external auth location and redirect URL observables.
|
||||
* If the response has succeeded, sets the redirect URL to user profile and redirects to external registration type authentication URL.
|
||||
* If the response has failed, shows an error notification.
|
||||
* @param override$ - The override$ observable.
|
||||
* @param registrationType - The registration type.
|
||||
*/
|
||||
handleUnauthenticatedUser(override$: Observable<RemoteData<EPerson>>, registrationType: string) {
|
||||
this.subs.push(
|
||||
combineLatest([
|
||||
override$,
|
||||
this.externalLoginService.getExternalAuthLocation(registrationType),
|
||||
this.authService.getRedirectUrl()])
|
||||
.subscribe(([response, location, redirectRoute]) => {
|
||||
if (response.hasSucceeded) {
|
||||
this.notificationService.success(
|
||||
this.translateService.get(
|
||||
'review-account-info.merge-data.notification.success',
|
||||
),
|
||||
);
|
||||
// set Redirect URL to User profile, so the user is redirected to the profile page after logging in
|
||||
this.authService.setRedirectUrl('/profile');
|
||||
const externalServerUrl = this.authService.getExternalServerRedirectUrl(
|
||||
this._window.nativeWindow.origin,
|
||||
redirectRoute,
|
||||
location,
|
||||
);
|
||||
// redirect to external registration type authentication url
|
||||
this.hardRedirectService.redirect(externalServerUrl);
|
||||
} else if (response.hasFailed) {
|
||||
this.notificationService.error(
|
||||
this.translateService.get(
|
||||
'review-account-info.merge-data.notification.error',
|
||||
),
|
||||
);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the user is authenticated.
|
||||
* @returns An observable that emits a boolean value indicating whether the user is authenticated or not.
|
||||
*/
|
||||
private isAuthenticated(): Observable<boolean> {
|
||||
return this.authService.isAuthenticated();
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare the data to compare and display:
|
||||
* -> For each metadata from the registration token, get the current value from the eperson.
|
||||
* -> Label is the metadata key without the prefix e.g `eperson.` but only `email`
|
||||
* -> Identifier is the metadata key with the prefix e.g `eperson.lastname`
|
||||
* -> Override value is false by default
|
||||
* @returns List of data to compare
|
||||
*/
|
||||
private prepareDataToCompare(): ReviewAccountInfoData[] {
|
||||
const dataToCompare: ReviewAccountInfoData[] = [];
|
||||
Object.entries(this.registrationData.registrationMetadata).forEach(
|
||||
([key, value]) => {
|
||||
// eperson.orcid is not always present in the registration metadata,
|
||||
// so display netId instead and skip it in the metadata in order not to have duplicate data.
|
||||
if (value[0].value === this.registrationData.netId) {
|
||||
return;
|
||||
}
|
||||
dataToCompare.push({
|
||||
label: key.split('.')?.[1] ?? key.split('.')?.[0],
|
||||
currentValue: value[0]?.overrides ?? '',
|
||||
receivedValue: value[0].value,
|
||||
overrideValue: false,
|
||||
identifier: key,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
return dataToCompare;
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.subs.filter((s) => hasValue(s)).forEach((sub) => sub.unsubscribe());
|
||||
}
|
||||
}
|
@@ -0,0 +1,28 @@
|
||||
import { Component } from '@angular/core';
|
||||
|
||||
import { ThemedComponent } from '../shared/theme-support/themed.component';
|
||||
import { ExternalLoginReviewAccountInfoPageComponent } from './external-login-review-account-info-page.component';
|
||||
|
||||
/**
|
||||
* Themed wrapper for ExternalLoginReviewAccountInfoPageComponent
|
||||
*/
|
||||
@Component({
|
||||
selector: 'ds-external-login-page',
|
||||
styleUrls: [],
|
||||
templateUrl: './../shared/theme-support/themed.component.html',
|
||||
standalone: true,
|
||||
imports: [ExternalLoginReviewAccountInfoPageComponent],
|
||||
})
|
||||
export class ThemedExternalLoginReviewAccountInfoPageComponent extends ThemedComponent<ExternalLoginReviewAccountInfoPageComponent> {
|
||||
protected getComponentName(): string {
|
||||
return 'ExternalLoginReviewAccountInfoPageComponent';
|
||||
}
|
||||
|
||||
protected importThemedComponent(themeName: string): Promise<any> {
|
||||
return import(`../../themes/${themeName}/app/external-login-review-account-info/external-login-review-account-info-page.component`);
|
||||
}
|
||||
|
||||
protected importUnthemedComponent(): Promise<any> {
|
||||
return import(`./external-login-review-account-info-page.component`);
|
||||
}
|
||||
}
|
@@ -14,7 +14,7 @@ describe('registrationResolver', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
epersonRegistrationService = jasmine.createSpyObj('epersonRegistrationService', {
|
||||
searchByToken: createSuccessfulRemoteDataObject$(registration),
|
||||
searchByTokenAndUpdateData: createSuccessfulRemoteDataObject$(registration),
|
||||
});
|
||||
resolver = registrationResolver;
|
||||
});
|
||||
|
@@ -17,7 +17,7 @@ export const registrationResolver: ResolveFn<RemoteData<Registration>> = (
|
||||
epersonRegistrationService: EpersonRegistrationService = inject(EpersonRegistrationService),
|
||||
): Observable<RemoteData<Registration>> => {
|
||||
const token = route.params.token;
|
||||
return epersonRegistrationService.searchByToken(token).pipe(
|
||||
return epersonRegistrationService.searchByTokenAndUpdateData(token).pipe(
|
||||
getFirstCompletedRemoteData(),
|
||||
);
|
||||
};
|
||||
|
@@ -53,7 +53,7 @@ describe('registrationGuard', () => {
|
||||
});
|
||||
|
||||
epersonRegistrationService = jasmine.createSpyObj('epersonRegistrationService', {
|
||||
searchByToken: observableOf(registrationRD),
|
||||
searchByTokenAndUpdateData: observableOf(registrationRD),
|
||||
});
|
||||
router = jasmine.createSpyObj('router', {
|
||||
navigateByUrl: Promise.resolve(),
|
||||
@@ -71,7 +71,7 @@ describe('registrationGuard', () => {
|
||||
describe('canActivate', () => {
|
||||
describe('when searchByToken returns a successful response', () => {
|
||||
beforeEach(() => {
|
||||
(epersonRegistrationService.searchByToken as jasmine.Spy).and.returnValue(observableOf(registrationRD));
|
||||
(epersonRegistrationService.searchByTokenAndUpdateData as jasmine.Spy).and.returnValue(observableOf(registrationRD));
|
||||
});
|
||||
|
||||
it('should return true', (done) => {
|
||||
@@ -98,7 +98,7 @@ describe('registrationGuard', () => {
|
||||
|
||||
describe('when searchByToken returns a 404 response', () => {
|
||||
beforeEach(() => {
|
||||
(epersonRegistrationService.searchByToken as jasmine.Spy).and.returnValue(createFailedRemoteDataObject$('Not Found', 404));
|
||||
(epersonRegistrationService.searchByTokenAndUpdateData as jasmine.Spy).and.returnValue(createFailedRemoteDataObject$('Not Found', 404));
|
||||
});
|
||||
|
||||
it('should redirect', () => {
|
||||
|
@@ -26,7 +26,7 @@ export const registrationGuard: CanActivateFn = (
|
||||
router: Router = inject(Router),
|
||||
): Observable<boolean> => {
|
||||
const token = route.params.token;
|
||||
return epersonRegistrationService.searchByToken(token).pipe(
|
||||
return epersonRegistrationService.searchByTokenAndUpdateData(token).pipe(
|
||||
getFirstCompletedRemoteData(),
|
||||
redirectOn4xx(router, authService),
|
||||
map((rd) => {
|
||||
|
@@ -2,4 +2,3 @@
|
||||
*ngComponentOutlet="getAuthMethodContent();
|
||||
injector: objectInjector;">
|
||||
</ng-container>
|
||||
|
||||
|
@@ -7,10 +7,9 @@ import {
|
||||
} from '@angular/core';
|
||||
|
||||
import { AuthMethod } from '../../../core/auth/models/auth.method';
|
||||
import {
|
||||
AuthMethodTypeComponent,
|
||||
rendersAuthMethodType,
|
||||
} from '../methods/log-in.methods-decorator';
|
||||
import { AuthMethodTypeComponent } from '../methods/auth-methods.type';
|
||||
import { AUTH_METHOD_FOR_DECORATOR_MAP } from '../methods/log-in.methods-decorator';
|
||||
import { rendersAuthMethodType } from '../methods/log-in.methods-decorator.utils';
|
||||
|
||||
/**
|
||||
* This component represents a component container for log-in methods available.
|
||||
@@ -60,7 +59,7 @@ export class LogInContainerComponent implements OnInit {
|
||||
* Find the correct component based on the AuthMethod's type
|
||||
*/
|
||||
getAuthMethodContent(): AuthMethodTypeComponent {
|
||||
return rendersAuthMethodType(this.authMethod.authMethodType);
|
||||
return rendersAuthMethodType(AUTH_METHOD_FOR_DECORATOR_MAP, this.authMethod.authMethodType);
|
||||
}
|
||||
|
||||
}
|
||||
|
@@ -9,16 +9,14 @@ import {
|
||||
select,
|
||||
Store,
|
||||
} from '@ngrx/store';
|
||||
import {
|
||||
map,
|
||||
Observable,
|
||||
} from 'rxjs';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
import { AuthService } from '../../core/auth/auth.service';
|
||||
import { AuthMethodsService } from '../../core/auth/auth-methods.service';
|
||||
import { AuthMethod } from '../../core/auth/models/auth.method';
|
||||
import { AuthMethodType } from '../../core/auth/models/auth.method-type';
|
||||
import {
|
||||
getAuthenticationError,
|
||||
getAuthenticationMethods,
|
||||
isAuthenticated,
|
||||
isAuthenticationLoading,
|
||||
} from '../../core/auth/selectors';
|
||||
@@ -26,7 +24,7 @@ import { CoreState } from '../../core/core-state.model';
|
||||
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 { AUTH_METHOD_FOR_DECORATOR_MAP } from './methods/log-in.methods-decorator';
|
||||
|
||||
@Component({
|
||||
selector: 'ds-base-log-in',
|
||||
@@ -44,6 +42,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[]}
|
||||
@@ -64,17 +71,12 @@ export class LogInComponent implements OnInit {
|
||||
|
||||
constructor(private store: Store<CoreState>,
|
||||
private authService: AuthService,
|
||||
private authMethodsService: AuthMethodsService,
|
||||
) {
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.authMethods = this.store.pipe(
|
||||
select(getAuthenticationMethods),
|
||||
map((methods: AuthMethod[]) => methods
|
||||
.filter((authMethod: AuthMethod) => rendersAuthMethodType(authMethod.authMethodType) !== undefined)
|
||||
.sort((method1: AuthMethod, method2: AuthMethod) => method1.position - method2.position),
|
||||
),
|
||||
);
|
||||
this.authMethods = this.authMethodsService.getAuthMethods(AUTH_METHOD_FOR_DECORATOR_MAP, this.excludedAuthMethod);
|
||||
|
||||
// set loading
|
||||
this.loading = this.store.pipe(select(isAuthenticationLoading));
|
||||
|
6
src/app/shared/log-in/methods/auth-methods.type.ts
Normal file
6
src/app/shared/log-in/methods/auth-methods.type.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { LogInExternalProviderComponent } from './log-in-external-provider/log-in-external-provider.component';
|
||||
import { LogInPasswordComponent } from './password/log-in-password.component';
|
||||
|
||||
export type AuthMethodTypeComponent =
|
||||
typeof LogInPasswordComponent |
|
||||
typeof LogInExternalProviderComponent;
|
@@ -108,8 +108,7 @@ describe('LogInExternalProviderComponent', () => {
|
||||
|
||||
component.redirectToExternalProvider();
|
||||
|
||||
expect(setHrefSpy).toHaveBeenCalledWith(currentUrl);
|
||||
|
||||
expect(hardRedirectService.redirect).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not set a new redirectUrl', () => {
|
||||
|
@@ -23,11 +23,7 @@ import {
|
||||
NativeWindowRef,
|
||||
NativeWindowService,
|
||||
} from '../../../../core/services/window.service';
|
||||
import { URLCombiner } from '../../../../core/url-combiner/url-combiner';
|
||||
import {
|
||||
isEmpty,
|
||||
isNotNull,
|
||||
} from '../../../empty.util';
|
||||
import { isEmpty } from '../../../empty.util';
|
||||
|
||||
@Component({
|
||||
selector: 'ds-log-in-external-provider',
|
||||
@@ -104,24 +100,14 @@ export class LogInExternalProviderComponent implements OnInit {
|
||||
} else if (isEmpty(redirectRoute)) {
|
||||
redirectRoute = '/';
|
||||
}
|
||||
const correctRedirectUrl = new URLCombiner(this._window.nativeWindow.origin, redirectRoute).toString();
|
||||
|
||||
let externalServerUrl = this.location;
|
||||
const myRegexp = /\?redirectUrl=(.*)/g;
|
||||
const match = myRegexp.exec(this.location);
|
||||
const redirectUrlFromServer = (match && match[1]) ? match[1] : null;
|
||||
|
||||
// Check whether the current page is different from the redirect url received from rest
|
||||
if (isNotNull(redirectUrlFromServer) && redirectUrlFromServer !== correctRedirectUrl) {
|
||||
// change the redirect url with the current page url
|
||||
const newRedirectUrl = `?redirectUrl=${correctRedirectUrl}`;
|
||||
externalServerUrl = this.location.replace(/\?redirectUrl=(.*)/g, newRedirectUrl);
|
||||
}
|
||||
|
||||
// redirect to shibboleth authentication url
|
||||
const externalServerUrl = this.authService.getExternalServerRedirectUrl(
|
||||
this._window.nativeWindow.origin,
|
||||
redirectRoute,
|
||||
this.location,
|
||||
);
|
||||
// redirect to shibboleth/orcid/(external) authentication url
|
||||
this.hardRedirectService.redirect(externalServerUrl);
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
getButtonLabel() {
|
||||
|
@@ -1,11 +1,8 @@
|
||||
import { AuthMethodType } from '../../../core/auth/models/auth.method-type';
|
||||
import { AuthMethodTypeComponent } from './auth-methods.type';
|
||||
import { LogInExternalProviderComponent } from './log-in-external-provider/log-in-external-provider.component';
|
||||
import { LogInPasswordComponent } from './password/log-in-password.component';
|
||||
|
||||
export type AuthMethodTypeComponent =
|
||||
typeof LogInPasswordComponent |
|
||||
typeof LogInExternalProviderComponent;
|
||||
|
||||
export const AUTH_METHOD_FOR_DECORATOR_MAP = new Map<AuthMethodType, AuthMethodTypeComponent>([
|
||||
[AuthMethodType.Password, LogInPasswordComponent],
|
||||
[AuthMethodType.Shibboleth, LogInExternalProviderComponent],
|
||||
@@ -25,7 +22,3 @@ export function renderAuthMethodFor(authMethodType: AuthMethodType) {
|
||||
AUTH_METHOD_FOR_DECORATOR_MAP.set(authMethodType, objectElement);
|
||||
};
|
||||
}
|
||||
|
||||
export function rendersAuthMethodType(authMethodType: AuthMethodType) {
|
||||
return AUTH_METHOD_FOR_DECORATOR_MAP.get(authMethodType);
|
||||
}
|
||||
|
@@ -0,0 +1,15 @@
|
||||
import { AuthMethodType } from '../../../core/auth/models/auth.method-type';
|
||||
import { AuthMethodTypeComponent } from './auth-methods.type';
|
||||
|
||||
/**
|
||||
* Retrieves the authentication method component for a specific authentication method type.
|
||||
* @param authMethods A map of authentication method types to their corresponding components
|
||||
* @param authMethodType The specific authentication method type to retrieve
|
||||
* @returns The component associated with the given authentication method type, or undefined if not found
|
||||
*/
|
||||
export function rendersAuthMethodType(
|
||||
authMethods: Map<AuthMethodType, AuthMethodTypeComponent>,
|
||||
authMethodType: AuthMethodType,
|
||||
) {
|
||||
return authMethods.get(authMethodType);
|
||||
}
|
@@ -3,6 +3,7 @@ import {
|
||||
Input,
|
||||
} from '@angular/core';
|
||||
|
||||
import { AuthMethodType } from '../../core/auth/models/auth.method-type';
|
||||
import { ThemedComponent } from '../theme-support/themed.component';
|
||||
import { LogInComponent } from './log-in.component';
|
||||
|
||||
@@ -20,8 +21,12 @@ export class ThemedLogInComponent extends ThemedComponent<LogInComponent> {
|
||||
|
||||
@Input() isStandalonePage: boolean;
|
||||
|
||||
@Input() excludedAuthMethod: AuthMethodType;
|
||||
|
||||
@Input() showRegisterLink = true;
|
||||
|
||||
protected inAndOutputNames: (keyof LogInComponent & keyof this)[] = [
|
||||
'isStandalonePage',
|
||||
'isStandalonePage', 'excludedAuthMethod', 'showRegisterLink',
|
||||
];
|
||||
|
||||
protected getComponentName(): string {
|
||||
|
@@ -29,4 +29,16 @@ export class AuthServiceMock {
|
||||
public isUserIdle(): Observable<boolean> {
|
||||
return observableOf(false);
|
||||
}
|
||||
|
||||
public getImpersonateID(): string {
|
||||
return null;
|
||||
}
|
||||
|
||||
public getRedirectUrl(): Observable<string> {
|
||||
return;
|
||||
}
|
||||
|
||||
public getExternalServerRedirectUrl(): string {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
@@ -3,6 +3,7 @@ import {
|
||||
of as observableOf,
|
||||
} from 'rxjs';
|
||||
|
||||
import { RetrieveAuthMethodsAction } from '../../core/auth/auth.actions';
|
||||
import { AuthMethod } from '../../core/auth/models/auth.method';
|
||||
import { AuthMethodType } from '../../core/auth/models/auth.method-type';
|
||||
import { AuthStatus } from '../../core/auth/models/auth-status.model';
|
||||
@@ -128,6 +129,7 @@ export class AuthServiceStub {
|
||||
checkAuthenticationCookie() {
|
||||
return;
|
||||
}
|
||||
|
||||
setExternalAuthStatus(externalCookie: boolean) {
|
||||
this._isExternalAuth = externalCookie;
|
||||
}
|
||||
@@ -179,4 +181,16 @@ export class AuthServiceStub {
|
||||
clearRedirectUrl() {
|
||||
return;
|
||||
}
|
||||
|
||||
public replaceToken(token: AuthTokenInfo) {
|
||||
return token;
|
||||
}
|
||||
|
||||
getRetrieveAuthMethodsAction(authStatus: AuthStatus): RetrieveAuthMethodsAction {
|
||||
return;
|
||||
}
|
||||
|
||||
public getExternalServerRedirectUrl(redirectRoute: string, location: string) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
@@ -1824,6 +1824,16 @@
|
||||
|
||||
"confirmation-modal.delete-subscription.confirm": "Delete",
|
||||
|
||||
"confirmation-modal.review-account-info.header": "Save the changes",
|
||||
|
||||
"confirmation-modal.review-account-info.info": "Are you sure you want to save the changes to your profile",
|
||||
|
||||
"confirmation-modal.review-account-info.cancel": "Cancel",
|
||||
|
||||
"confirmation-modal.review-account-info.confirm": "Confirm",
|
||||
|
||||
"confirmation-modal.review-account-info.save": "Save",
|
||||
|
||||
"error.bitstream": "Error fetching bitstream",
|
||||
|
||||
"error.browse-by": "Error fetching items",
|
||||
@@ -6852,6 +6862,86 @@
|
||||
|
||||
"dynamic-form-array.sortable-list.label": "Sortable list",
|
||||
|
||||
"external-login.component.or": "or",
|
||||
|
||||
"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-label": "User Email",
|
||||
|
||||
"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 emait to the provided address to validate your input. <br> Please follow the instructions in the email to complete the login process.",
|
||||
|
||||
"external-login.provide-email.header": "Provide email",
|
||||
|
||||
"external-login.provide-email.button.label": "Send Verification link",
|
||||
|
||||
"external-login-validation.review-account-info.header": "Review your account information",
|
||||
|
||||
"external-login-validation.review-account-info.info": "The information received from ORCID differs from the one recorded in your profile. <br /> Please review them and decide if you want to update any of them.After saving you will be redirected to your profile page.",
|
||||
|
||||
"external-login-validation.review-account-info.table.header.information": "Information",
|
||||
|
||||
"external-login-validation.review-account-info.table.header.received-value": "Received value",
|
||||
|
||||
"external-login-validation.review-account-info.table.header.current-value": "Current value",
|
||||
|
||||
"external-login-validation.review-account-info.table.header.action": "Override",
|
||||
|
||||
"external-login-validation.review-account-info.table.row.not-applicable": "N/A",
|
||||
|
||||
"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.",
|
||||
|
||||
"external-login-page.orcid-confirmation.firstname": "First name",
|
||||
|
||||
"external-login-page.orcid-confirmation.firstname.label": "First name",
|
||||
|
||||
"external-login-page.orcid-confirmation.lastname": "Last name",
|
||||
|
||||
"external-login-page.orcid-confirmation.lastname.label": "Last name",
|
||||
|
||||
"external-login-page.orcid-confirmation.netid": "Account Identifier",
|
||||
|
||||
"external-login-page.orcid-confirmation.netid.placeholder": "xxxx-xxxx-xxxx-xxxx",
|
||||
|
||||
"external-login-page.orcid-confirmation.email": "Email",
|
||||
|
||||
"external-login-page.orcid-confirmation.email.label": "Email",
|
||||
|
||||
"search.filters.access_status.open.access": "Open access",
|
||||
|
||||
"search.filters.access_status.restricted": "Restricted access",
|
||||
|
@@ -2305,6 +2305,26 @@
|
||||
// "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": "Are you sure you want to save the changes to your profile",
|
||||
// TODO New key - Add a translation
|
||||
"confirmation-modal.review-account-info.info": "Are you sure you want to save the changes to 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": "Confirm",
|
||||
// TODO New key - Add a translation
|
||||
"confirmation-modal.review-account-info.confirm": "Confirm",
|
||||
|
||||
// "confirmation-modal.review-account-info.save": "Save",
|
||||
// TODO New key - Add a translation
|
||||
"confirmation-modal.review-account-info.save": "Save",
|
||||
|
||||
// "error.bitstream": "Error fetching bitstream",
|
||||
"error.bitstream": "Errore durante il recupero del bitstream",
|
||||
|
||||
@@ -4193,8 +4213,6 @@
|
||||
// "login.breadcrumbs": "Login",
|
||||
"login.breadcrumbs": "Accesso",
|
||||
|
||||
|
||||
|
||||
// "logout.form.header": "Log out from DSpace",
|
||||
"logout.form.header": "Disconnettersi da DSpace",
|
||||
|
||||
@@ -7838,5 +7856,163 @@
|
||||
// "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.component.or": "or"
|
||||
// TODO New key - Add a translation
|
||||
"external-login.component.or": "or",
|
||||
|
||||
// "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-label": "User Email",
|
||||
// TODO New key - Add a translation
|
||||
"external-login.confirmation.email-label": "User Email",
|
||||
|
||||
// "external-login.confirmation.email-required": "Email is required.",
|
||||
// TODO New key - Add a translation
|
||||
"external-login.confirmation.email-required": "Email is required.",
|
||||
|
||||
// "external-login.confirmation.email-invalid": "Invalid email format.",
|
||||
// TODO New key - Add a translation
|
||||
"external-login.confirmation.email-invalid": "Invalid email format.",
|
||||
|
||||
// "external-login.confirm.button.label": "Confirm this email",
|
||||
// TODO New key - Add a translation
|
||||
"external-login.confirm.button.label": "Confirm this email",
|
||||
|
||||
// "external-login.confirm-email-sent.header": "Confirmation email sent",
|
||||
// TODO New key - Add a translation
|
||||
"external-login.confirm-email-sent.header": "Confirmation email sent",
|
||||
|
||||
// "external-login.confirm-email-sent.info": " We have sent an emait to the provided address to validate your input. <br> Please follow the instructions in the email to complete the login process.",
|
||||
// TODO New key - Add a translation
|
||||
"external-login.confirm-email-sent.info": " We have sent an emait to the provided address to validate your input. <br> Please follow the instructions in the email to complete the login process.",
|
||||
|
||||
// "external-login.provide-email.header": "Provide email",
|
||||
// TODO New key - Add a translation
|
||||
"external-login.provide-email.header": "Provide email",
|
||||
|
||||
// "external-login.provide-email.button.label": "Send Verification link",
|
||||
// TODO New key - Add a translation
|
||||
"external-login.provide-email.button.label": "Send Verification link",
|
||||
|
||||
// "external-login-validation.review-account-info.header": "Review your account information",
|
||||
// TODO New key - Add a translation
|
||||
"external-login-validation.review-account-info.header": "Review your account information",
|
||||
|
||||
// "external-login-validation.review-account-info.info": "The information received from ORCID differs from the one recorded in your profile. <br /> Please review them and decide if you want to update any of them.After saving you will be redirected to your profile page.",
|
||||
// TODO New key - Add a translation
|
||||
"external-login-validation.review-account-info.info": "The information received from ORCID differs from the one recorded in your profile. <br /> Please review them and decide if you want to update any of them.After saving you will be redirected to your profile page.",
|
||||
|
||||
// "external-login-validation.review-account-info.table.header.information": "Information",
|
||||
// TODO New key - Add a translation
|
||||
"external-login-validation.review-account-info.table.header.information": "Information",
|
||||
|
||||
// "external-login-validation.review-account-info.table.header.received-value": "Received value",
|
||||
// TODO New key - Add a translation
|
||||
"external-login-validation.review-account-info.table.header.received-value": "Received value",
|
||||
|
||||
// "external-login-validation.review-account-info.table.header.current-value": "Current value",
|
||||
// TODO New key - Add a translation
|
||||
"external-login-validation.review-account-info.table.header.current-value": "Current value",
|
||||
|
||||
// "external-login-validation.review-account-info.table.header.action": "Override",
|
||||
// TODO New key - Add a translation
|
||||
"external-login-validation.review-account-info.table.header.action": "Override",
|
||||
|
||||
// "external-login-validation.review-account-info.table.row.not-applicable": "N/A",
|
||||
// TODO New key - Add a translation
|
||||
"external-login-validation.review-account-info.table.row.not-applicable": "N/A",
|
||||
|
||||
// "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.",
|
||||
|
||||
//"external-login-page.orcid-confirmation.firstname": "First name",
|
||||
// TODO New key - Add a translation
|
||||
"external-login-page.orcid-confirmation.firstname": "First name",
|
||||
|
||||
//"external-login-page.orcid-confirmation.firstname.label": "First name",
|
||||
// TODO New key - Add a translation
|
||||
"external-login-page.orcid-confirmation.firstname.label": "First name",
|
||||
|
||||
// "external-login-page.orcid-confirmation.lastname": "Last name",
|
||||
// TODO New key - Add a translation
|
||||
"external-login-page.orcid-confirmation.lastname": "Last name",
|
||||
|
||||
// "external-login-page.orcid-confirmation.lastname.label": "Last name",
|
||||
// TODO New key - Add a translation
|
||||
"external-login-page.orcid-confirmation.lastname.label": "Last name",
|
||||
|
||||
// "external-login-page.orcid-confirmation.netid": "Account Identifier",
|
||||
// TODO New key - Add a translation
|
||||
"external-login-page.orcid-confirmation.netid": "Account Identifier",
|
||||
|
||||
// "external-login-page.orcid-confirmation.netid.placeholder": "xxxx-xxxx-xxxx-xxxx",
|
||||
// TODO New key - Add a translation
|
||||
"external-login-page.orcid-confirmation.netid.placeholder": "xxxx-xxxx-xxxx-xxxx",
|
||||
|
||||
// "external-login-page.orcid-confirmation.email": "Email",
|
||||
// TODO New key - Add a translation
|
||||
"external-login-page.orcid-confirmation.email": "Email",
|
||||
|
||||
// "external-login-page.orcid-confirmation.email.label": "Email"
|
||||
// TODO New key - Add a translation
|
||||
"external-login-page.orcid-confirmation.email.label": "Email",
|
||||
}
|
||||
|
@@ -1,5 +1,6 @@
|
||||
import { AsyncPipe } from '@angular/common';
|
||||
import { Component } from '@angular/core';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { ThemedLoadingComponent } from 'src/app/shared/loading/themed-loading.component';
|
||||
import { LogInContainerComponent } from 'src/app/shared/log-in/container/log-in-container.component';
|
||||
|
||||
@@ -12,7 +13,7 @@ import { LogInComponent as BaseComponent } from '../../../../../app/shared/log-i
|
||||
// styleUrls: ['./log-in.component.scss'],
|
||||
styleUrls: ['../../../../../app/shared/log-in/log-in.component.scss'],
|
||||
standalone: true,
|
||||
imports: [ ThemedLoadingComponent, LogInContainerComponent, AsyncPipe],
|
||||
imports: [ ThemedLoadingComponent, LogInContainerComponent, AsyncPipe, TranslateModule ],
|
||||
})
|
||||
export class LogInComponent extends BaseComponent {
|
||||
}
|
||||
|
Reference in New Issue
Block a user