diff --git a/feature-libs/user/_index.scss b/feature-libs/user/_index.scss index 280d865eba7..11254250211 100644 --- a/feature-libs/user/_index.scss +++ b/feature-libs/user/_index.scss @@ -6,7 +6,8 @@ $skipComponentStyles: () !default; $selectors: cx-address-book, cx-address-form, cx-suggested-addresses-dialog, - cx-login, cx-login-form, cx-register, cx-reset-password, cx-close-account, + cx-login, cx-login-form, cx-register, cx-otp-register-form, + cx-registration-verification-token-form, cx-reset-password, cx-close-account, cx-close-account-modal, cx-my-account-v2-profile, cx-my-account-v2-email, cx-my-account-v2-password, cx-otp-login-form, cx-verification-token-form, cx-verification-token-dialog !default; diff --git a/feature-libs/user/account/root/model/otp-login.model.ts b/feature-libs/user/account/root/model/otp-login.model.ts index fb26a114a51..b56e8eaba64 100644 --- a/feature-libs/user/account/root/model/otp-login.model.ts +++ b/feature-libs/user/account/root/model/otp-login.model.ts @@ -7,7 +7,7 @@ export interface VerificationTokenCreation { purpose: string; loginId: string; - password: string; + password?: string; } export interface VerificationToken { diff --git a/feature-libs/user/account/root/user-account-root.module.ts b/feature-libs/user/account/root/user-account-root.module.ts index 4dc75ee5a58..63335d726f6 100644 --- a/feature-libs/user/account/root/user-account-root.module.ts +++ b/feature-libs/user/account/root/user-account-root.module.ts @@ -24,6 +24,7 @@ export function defaultUserAccountComponentsConfig(): CmsConfig { 'ReturningCustomerRegisterComponent', 'MyAccountViewUserComponent', 'ReturningCustomerOTPLoginComponent', + 'RegisterCustomerWithOTPComponent', ], }, // by default core is bundled together with components diff --git a/feature-libs/user/profile/assets/translations/en/userProfile.json b/feature-libs/user/profile/assets/translations/en/userProfile.json index 23e2cc61cd3..b5d5393acb8 100644 --- a/feature-libs/user/profile/assets/translations/en/userProfile.json +++ b/feature-libs/user/profile/assets/translations/en/userProfile.json @@ -27,6 +27,7 @@ "termsAndConditions": "Terms & Conditions", "signIn": "I already have an account. Sign In", "register": "Register", + "furtherRegistration": "Continue", "confirmNewPassword": "Confirm New Password", "resetPassword": "Reset Password", "createAccount": "Create an account", @@ -57,7 +58,17 @@ "bothPasswordMustMatch": "Both password must match", "titleRequired": "Title is required.", "postRegisterMessage": "Please log in with provided credentials.", - "postRegisterSuccessMessage": "Successful Registration: Please log in with provided credentials" + "postRegisterSuccessMessage": "Your account has been successfully created! Please log in with provided credentials", + "verificationTokenForm": { + "createVerificationToken": "Verification code has been sent to {{target}}. Please enter the code.", + "sendRateLime": "in {{waitTime}} seconds", + "resend": "Resend", + "verificationCode": { + "label": "Verification Code", + "placeholder": "Enter Verification Code" + }, + "back": "Back" + } }, "forgottenPassword": { "resetPassword": "Reset password", diff --git a/feature-libs/user/profile/components/otp-login-register/index.ts b/feature-libs/user/profile/components/otp-login-register/index.ts new file mode 100644 index 00000000000..29de7d62b68 --- /dev/null +++ b/feature-libs/user/profile/components/otp-login-register/index.ts @@ -0,0 +1,8 @@ +/* + * SPDX-FileCopyrightText: 2025 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './otp-login-register.component'; +export * from './otp-login-register.module'; diff --git a/feature-libs/user/profile/components/otp-login-register/otp-login-register.component.html b/feature-libs/user/profile/components/otp-login-register/otp-login-register.component.html new file mode 100644 index 00000000000..a89dcc38dff --- /dev/null +++ b/feature-libs/user/profile/components/otp-login-register/otp-login-register.component.html @@ -0,0 +1,246 @@ +
+
+
+
+
+
+
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+
+ +
+
+ +
+
+
+ +
+
+
+ +
+
+ +
+
+ + + + +
+
+
+
+
+ + + + + + +
+
diff --git a/feature-libs/user/profile/components/otp-login-register/otp-login-register.component.spec.ts b/feature-libs/user/profile/components/otp-login-register/otp-login-register.component.spec.ts new file mode 100644 index 00000000000..673f77e79fd --- /dev/null +++ b/feature-libs/user/profile/components/otp-login-register/otp-login-register.component.spec.ts @@ -0,0 +1,416 @@ +/* + * SPDX-FileCopyrightText: 2025 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ +import { Component, Pipe, PipeTransform } from '@angular/core'; +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { AbstractControl, ReactiveFormsModule } from '@angular/forms'; +import { By } from '@angular/platform-browser'; +import { RouterTestingModule } from '@angular/router/testing'; +import { NgSelectModule } from '@ng-select/ng-select'; +import { + ANONYMOUS_CONSENT_STATUS, + AnonymousConsent, + AnonymousConsentsConfig, + AnonymousConsentsService, + BaseSite, + BaseSiteService, + ClientAuthenticationTokenService, + ConsentTemplate, + GlobalMessageEntities, + GlobalMessageService, + GlobalMessageType, + I18nTestingModule, + LanguageService, + RoutingService, + SiteAdapter, + Title, +} from '@spartacus/core'; +import { + CaptchaModule, + FormErrorsModule, + NgSelectA11yModule, + PasswordVisibilityToggleModule, +} from '@spartacus/storefront'; +import { MockFeatureDirective } from 'projects/storefrontlib/shared/test/mock-feature-directive'; +import { EMPTY, Observable, of } from 'rxjs'; + +import createSpy = jasmine.createSpy; +import { ONE_TIME_PASSWORD_REGISTRATION_PURPOSE } from '../user-account-constants'; +import { OneTimePasswordRegisterComponent } from './otp-login-register.component'; +import { VerificationTokenFacade } from '@spartacus/user/account/root'; +import { RegisterComponentService } from '../register'; + +const mockRegisterFormData: any = { + titleCode: 'Mr', + firstName: 'John', + lastName: 'Doe', + email: 'johndoe@thebest.john.intheworld.com', + termsandconditions: true, + newsletter: true, + captcha: true, +}; + +const mockTitlesList: Title[] = [ + { + code: 'mr', + name: 'Mr.', + }, + { + code: 'mrs', + name: 'Mrs.', + }, +]; + +@Pipe({ + name: 'cxUrl', +}) +class MockUrlPipe implements PipeTransform { + transform() {} +} + +@Component({ + selector: 'cx-spinner', + template: '', +}) +class MockSpinnerComponent {} + +class MockGlobalMessageService { + add = createSpy(); + remove = createSpy(); + get() { + return EMPTY; + } +} + +class MockRoutingService { + go = createSpy(); +} + +class MockAnonymousConsentsService { + getConsent(_templateCode: string): Observable { + return EMPTY; + } + getTemplate(_templateCode: string): Observable { + return EMPTY; + } + withdrawConsent(_templateCode: string): void {} + giveConsent(_templateCode: string): void {} + isConsentGiven(_consent: AnonymousConsent): boolean { + return true; + } +} + +class MockVerificationTokenFacade implements Partial { + createVerificationToken = createSpy().and.returnValue( + of({ + expiresIn: '300', + tokenId: 'mockTokenId', + }) + ); +} + +const mockAnonymousConsentsConfig: AnonymousConsentsConfig = { + anonymousConsents: { + registerConsent: 'MARKETING', + requiredConsents: ['MARKETING'], + }, +}; + +class MockRegisterComponentService + implements Partial +{ + getTitles = createSpy().and.returnValue(of(mockTitlesList)); + getAdditionalConsents = createSpy(); + generateAdditionalConsentsFormControl = createSpy(); +} + +class MockSiteAdapter { + public loadBaseSite(siteUid?: string): Observable { + return of({ + uid: siteUid, + captchaConfig: { + enabled: true, + publicKey: 'mock-key', + }, + }); + } +} + +class MockBaseSiteService { + getActive(): Observable { + return of('mock-site'); + } +} + +class MockLanguageService { + getActive(): Observable { + return of('mock-lang'); + } +} + +class MockClientAuthenticationTokenService + implements Partial +{ + loadClientAuthenticationToken = createSpy().and.returnValue(of(undefined)); +} + +describe('OneTimePasswordRegisterComponent', () => { + let controls: any; + let component: OneTimePasswordRegisterComponent; + let fixture: ComponentFixture; + let mockRoutingService: RoutingService; + + let globalMessageService: GlobalMessageService; + let anonymousConsentService: AnonymousConsentsService; + let registrationVerificationTokenFacade: VerificationTokenFacade; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + ReactiveFormsModule, + RouterTestingModule, + I18nTestingModule, + FormErrorsModule, + NgSelectModule, + PasswordVisibilityToggleModule, + NgSelectA11yModule, + CaptchaModule, + ], + declarations: [ + OneTimePasswordRegisterComponent, + MockUrlPipe, + MockSpinnerComponent, + MockFeatureDirective, + ], + providers: [ + { + provide: RegisterComponentService, + useClass: MockRegisterComponentService, + }, + { + provide: GlobalMessageService, + useClass: MockGlobalMessageService, + }, + { + provide: RoutingService, + useClass: MockRoutingService, + }, + { + provide: AnonymousConsentsService, + useClass: MockAnonymousConsentsService, + }, + { + provide: AnonymousConsentsConfig, + useValue: mockAnonymousConsentsConfig, + }, + { + provide: SiteAdapter, + useClass: MockSiteAdapter, + }, + { + provide: BaseSiteService, + useClass: MockBaseSiteService, + }, + { + provide: LanguageService, + useClass: MockLanguageService, + }, + { + provide: ClientAuthenticationTokenService, + useClass: MockClientAuthenticationTokenService, + }, + { + provide: VerificationTokenFacade, + useClass: MockVerificationTokenFacade, + }, + ], + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(OneTimePasswordRegisterComponent); + globalMessageService = TestBed.inject(GlobalMessageService); + anonymousConsentService = TestBed.inject(AnonymousConsentsService); + registrationVerificationTokenFacade = TestBed.inject( + VerificationTokenFacade + ); + mockRoutingService = TestBed.inject(RoutingService); + + component = fixture.componentInstance; + + fixture.detectChanges(); + controls = component.registerForm.controls; + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + describe('submit button', () => { + it('should NOT be disabled', () => { + fixture = TestBed.createComponent(OneTimePasswordRegisterComponent); + fixture.detectChanges(); + const el: HTMLElement = fixture.debugElement.nativeElement; + const submitButton: HTMLElement = el.querySelector( + 'button[type="submit"]' + ); + expect(submitButton.hasAttribute('disabled')).toBeFalsy(); + }); + }); + + describe('ngOnInit', () => { + it('should load titles', () => { + component.ngOnInit(); + + let titleList: Title[]; + component.titles$ + .subscribe((data) => { + titleList = data; + }) + .unsubscribe(); + expect(titleList).toEqual(mockTitlesList); + }); + + it('should handle error when title code is required from the backend config', () => { + spyOn(globalMessageService, 'get').and.returnValue( + of({ + [GlobalMessageType.MSG_TYPE_ERROR]: [ + { raw: 'This field is required.' }, + ], + } as GlobalMessageEntities) + ); + component.ngOnInit(); + + expect(globalMessageService.remove).toHaveBeenCalledWith( + GlobalMessageType.MSG_TYPE_ERROR + ); + expect(globalMessageService.add).toHaveBeenCalledWith( + { + key: 'register.titleRequired', + }, + GlobalMessageType.MSG_TYPE_ERROR + ); + }); + }); + + describe('sendRegistrationVerificationToken', () => { + it('should create registration verification token with valid form', () => { + component.registerForm.patchValue(mockRegisterFormData); + component.ngOnInit(); + component.submitForm(); + expect( + registrationVerificationTokenFacade.createVerificationToken + ).toHaveBeenCalledWith({ + loginId: mockRegisterFormData.email.toLowerCase(), + purpose: ONE_TIME_PASSWORD_REGISTRATION_PURPOSE, + }); + }); + + it('should not create registration verification token with valid form', () => { + component.ngOnInit(); + component.submitForm(); + expect( + registrationVerificationTokenFacade.createVerificationToken + ).not.toHaveBeenCalled(); + }); + + it('should redirect to next register page', () => { + component.ngOnInit(); + component.sendRegistrationVerificationToken(); + + expect(mockRoutingService.go).toHaveBeenCalled(); + }); + }); + + const toggleAnonymousConsentMethod = 'toggleAnonymousConsent'; + describe(`${toggleAnonymousConsentMethod}`, () => { + it('should call anonymousConsentsService.giveConsent when the consent is given', () => { + spyOn(anonymousConsentService, 'giveConsent').and.stub(); + component.ngOnInit(); + + controls['newsletter'].setValue(true); + component.toggleAnonymousConsent(); + expect(anonymousConsentService.giveConsent).toHaveBeenCalled(); + }); + it('should call anonymousConsentsService.withdrawConsent when the consent is NOT given', () => { + spyOn(anonymousConsentService, 'withdrawConsent').and.stub(); + component.ngOnInit(); + + controls['newsletter'].setValue(false); + component.toggleAnonymousConsent(); + expect(anonymousConsentService.withdrawConsent).toHaveBeenCalled(); + }); + }); + + describe('isConsentGiven', () => { + it('should call anonymousConsentsService.isConsentGiven', () => { + spyOn(anonymousConsentService, 'isConsentGiven').and.stub(); + const mockConsent: AnonymousConsent = { + consentState: ANONYMOUS_CONSENT_STATUS.GIVEN, + }; + component.isConsentGiven(mockConsent); + expect(anonymousConsentService.isConsentGiven).toHaveBeenCalledWith( + mockConsent + ); + }); + }); + + const isConsentRequiredMethod = 'isConsentRequired'; + describe('isConsentRequired', () => { + it('should disable form when register consent is required', () => { + expect(component[isConsentRequiredMethod]()).toEqual(true); + }); + + it('should disable input when register consent is required', () => { + spyOn(component, isConsentRequiredMethod).and.returnValue(true); + fixture.detectChanges(); + expect(controls['newsletter'].status).toEqual('DISABLED'); + }); + }); + + describe('captcha', () => { + let captchaComponent; + beforeEach(() => { + captchaComponent = fixture.debugElement.query(By.css('cx-captcha')); + spyOn(component, 'sendRegistrationVerificationToken').and.callThrough(); + mockRegisterFormData.captcha = false; + component.registerForm.patchValue(mockRegisterFormData); + }); + + function getCaptchaControl( + component: OneTimePasswordRegisterComponent + ): AbstractControl { + return component.registerForm.get('captcha') as AbstractControl; + } + + it('should create captcha component', () => { + expect(captchaComponent).toBeTruthy(); + }); + + it('should enable captcha', () => { + captchaComponent.triggerEventHandler('enabled', true); + component.submitForm(); + + expect(getCaptchaControl(component).valid).toEqual(false); + expect(component.sendRegistrationVerificationToken).toHaveBeenCalledTimes( + 0 + ); + }); + + it('should confirm captcha', () => { + spyOn(component, 'captchaConfirmed').and.callThrough(); + + captchaComponent.triggerEventHandler('enabled', true); + captchaComponent.triggerEventHandler('confirmed', true); + component.submitForm(); + + expect(getCaptchaControl(component).value).toBe(true); + expect(getCaptchaControl(component).valid).toEqual(true); + expect(component.sendRegistrationVerificationToken).toHaveBeenCalledTimes( + 1 + ); + }); + }); +}); diff --git a/feature-libs/user/profile/components/otp-login-register/otp-login-register.component.ts b/feature-libs/user/profile/components/otp-login-register/otp-login-register.component.ts new file mode 100644 index 00000000000..c17aa8781f1 --- /dev/null +++ b/feature-libs/user/profile/components/otp-login-register/otp-login-register.component.ts @@ -0,0 +1,268 @@ +/* + * SPDX-FileCopyrightText: 2025 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Component, inject, OnDestroy, OnInit } from '@angular/core'; +import { + UntypedFormArray, + UntypedFormBuilder, + UntypedFormControl, + UntypedFormGroup, + Validators, +} from '@angular/forms'; +import { + AnonymousConsent, + AnonymousConsentsConfig, + AnonymousConsentsService, + ClientAuthenticationTokenService, + ConsentTemplate, + GlobalMessageEntities, + GlobalMessageService, + GlobalMessageType, + RoutingService, + useFeatureStyles, +} from '@spartacus/core'; +import { CustomFormValidators, sortTitles } from '@spartacus/storefront'; +import { Title } from '@spartacus/user/profile/root'; +import { + BehaviorSubject, + combineLatest, + filter, + map, + Observable, + Subscription, +} from 'rxjs'; + +import { ONE_TIME_PASSWORD_REGISTRATION_PURPOSE } from '../user-account-constants'; +import { RegisterComponentService } from '../register'; +import { + VerificationToken, + VerificationTokenCreation, + VerificationTokenFacade, +} from '@spartacus/user/account/root'; + +@Component({ + selector: 'cx-otp-register-form', + templateUrl: './otp-login-register.component.html', +}) +export class OneTimePasswordRegisterComponent implements OnInit, OnDestroy { + protected globalMessageService = inject(GlobalMessageService); + protected fb = inject(UntypedFormBuilder); + protected router = inject(RoutingService); + protected anonymousConsentsService = inject(AnonymousConsentsService); + protected anonymousConsentsConfig = inject(AnonymousConsentsConfig); + protected clientAuthenticationTokenService = inject( + ClientAuthenticationTokenService + ); + protected registerComponentService = inject(RegisterComponentService); + protected routingService = inject(RoutingService); + protected registrationVerificationTokenFacade = inject( + VerificationTokenFacade + ); + + titles$: Observable; + + isLoading$ = new BehaviorSubject(false); + + private subscription = new Subscription(); + + anonymousConsent$: Observable<{ + consent: AnonymousConsent | undefined; + template: string; + }>; + + registerForm: UntypedFormGroup = this.fb.group({ + titleCode: [null], + firstName: ['', Validators.required], + lastName: ['', Validators.required], + email: ['', [Validators.required, CustomFormValidators.emailValidator]], + newsletter: new UntypedFormControl({ + value: false, + disabled: this.isConsentRequired(), + }), + additionalConsents: + this.registerComponentService.generateAdditionalConsentsFormControl?.() ?? + this.fb.array([]), + termsandconditions: [false, Validators.requiredTrue], + captcha: [false, Validators.requiredTrue], + }); + + additionalRegistrationConsents: { + template: ConsentTemplate; + required: boolean; + }[]; + + get additionalConsents(): UntypedFormArray { + return this.registerForm?.get('additionalConsents') as UntypedFormArray; + } + + updateAdditionalConsents(event: MouseEvent, index: number) { + const { checked } = event.target as HTMLInputElement; + this.registerForm.value.additionalConsents[index] = checked; + } + + constructor() { + useFeatureStyles('a11yPasswordVisibliltyBtnValueOverflow'); + } + + ngOnInit() { + this.titles$ = this.registerComponentService.getTitles().pipe( + map((titles: Title[]) => { + return titles.sort(sortTitles); + }) + ); + + // TODO: Workaround: allow server for decide is titleCode mandatory (if yes, provide personalized message) + this.subscription.add( + this.globalMessageService + .get() + .pipe(filter((messages) => !!Object.keys(messages).length)) + .subscribe((globalMessageEntities: GlobalMessageEntities) => { + const messages = + globalMessageEntities && + globalMessageEntities[GlobalMessageType.MSG_TYPE_ERROR]; + + if ( + messages && + messages.some( + (message) => message.raw === 'This field is required.' + ) + ) { + this.globalMessageService.remove(GlobalMessageType.MSG_TYPE_ERROR); + this.globalMessageService.add( + { key: 'register.titleRequired' }, + GlobalMessageType.MSG_TYPE_ERROR + ); + } + }) + ); + + const registerConsent = + this.anonymousConsentsConfig?.anonymousConsents?.registerConsent ?? ''; + + this.anonymousConsent$ = combineLatest([ + this.anonymousConsentsService.getConsent(registerConsent), + this.anonymousConsentsService.getTemplate(registerConsent), + ]).pipe( + map( + ([consent, template]: [ + AnonymousConsent | undefined, + ConsentTemplate | undefined, + ]) => { + return { + consent, + template: template?.description ? template.description : '', + }; + } + ) + ); + + this.additionalRegistrationConsents = + this.registerComponentService?.getAdditionalConsents() || []; + + this.subscription.add( + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + this.registerForm.get('newsletter')?.valueChanges.subscribe(() => { + this.toggleAnonymousConsent(); + }) + ); + } + + submitForm(): void { + if (this.registerForm.valid) { + this.sendRegistrationVerificationToken(); + } else { + this.registerForm.markAllAsTouched(); + } + } + + sendRegistrationVerificationToken(): void { + this.isLoading$.next(true); + this.clientAuthenticationTokenService.loadClientAuthenticationToken(); + const registrationVerificationTokenCreation = + this.collectDataFromRegisterForm(); + this.registrationVerificationTokenFacade + .createVerificationToken(registrationVerificationTokenCreation) + .subscribe({ + next: (result: VerificationToken) => + this.goToVerificationTokenForm(result), + error: () => this.isLoading$.next(false), + complete: () => this.onCreateRegistrationVerificationTokenComplete(), + }); + } + + protected onCreateRegistrationVerificationTokenComplete(): void { + this.isLoading$.next(false); + } + + collectDataFromRegisterForm(): VerificationTokenCreation { + return { + loginId: this.registerForm.value.email.toLowerCase(), + purpose: ONE_TIME_PASSWORD_REGISTRATION_PURPOSE, + }; + } + + protected goToVerificationTokenForm( + registrationVerificationToken: VerificationToken + ): void { + this.routingService.go( + { + cxRoute: 'verifyTokenForRegistration', + }, + { + state: { + loginId: this.registerForm.value.email.toLowerCase(), + tokenId: registrationVerificationToken.tokenId, + expiresIn: registrationVerificationToken.expiresIn, + titleCode: this.registerForm.value.titleCode, + firstName: this.registerForm.value.firstName, + lastName: this.registerForm.value.lastName, + }, + } + ); + } + + isConsentGiven(consent: AnonymousConsent | undefined): boolean { + return this.anonymousConsentsService.isConsentGiven(consent); + } + + private isConsentRequired(): boolean { + const requiredConsents = + this.anonymousConsentsConfig?.anonymousConsents?.requiredConsents; + const registerConsent = + this.anonymousConsentsConfig?.anonymousConsents?.registerConsent; + + if (requiredConsents && registerConsent) { + return requiredConsents.includes(registerConsent); + } + + return false; + } + + toggleAnonymousConsent(): void { + const registerConsent = + this.anonymousConsentsConfig?.anonymousConsents?.registerConsent; + + if (registerConsent) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + if (Boolean(this.registerForm.get('newsletter')?.value)) { + this.anonymousConsentsService.giveConsent(registerConsent); + } else { + this.anonymousConsentsService.withdrawConsent(registerConsent); + } + } + } + + /** + * Triggered via CaptchaComponent when a user confirms captcha + */ + captchaConfirmed() { + this.registerForm.get('captcha')?.setValue(true); + } + + ngOnDestroy() { + this.subscription.unsubscribe(); + } +} diff --git a/feature-libs/user/profile/components/otp-login-register/otp-login-register.module.ts b/feature-libs/user/profile/components/otp-login-register/otp-login-register.module.ts new file mode 100644 index 00000000000..2cfbff0579a --- /dev/null +++ b/feature-libs/user/profile/components/otp-login-register/otp-login-register.module.ts @@ -0,0 +1,67 @@ +/* + * SPDX-FileCopyrightText: 2025 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { RouterModule } from '@angular/router'; +import { + CmsConfig, + FeaturesConfigModule, + I18nModule, + NotAuthGuard, + provideDefaultConfig, + UrlModule, +} from '@spartacus/core'; +import { + BtnLikeLinkModule, + CaptchaModule, + FormErrorsModule, + NgSelectA11yModule, + PageSlotModule, + SpinnerModule, +} from '@spartacus/storefront'; +import { ReactiveFormsModule, UntypedFormBuilder } from '@angular/forms'; +import { NgSelectModule } from '@ng-select/ng-select'; +import { UserRegisterFacade } from '@spartacus/user/profile/root'; +import { OneTimePasswordRegisterComponent } from './otp-login-register.component'; +import { RegisterComponentService } from '../register'; + +@NgModule({ + imports: [ + CommonModule, + RouterModule, + UrlModule, + PageSlotModule, + I18nModule, + FeaturesConfigModule, + BtnLikeLinkModule, + ReactiveFormsModule, + NgSelectModule, + NgSelectA11yModule, + CaptchaModule, + SpinnerModule, + FormErrorsModule, + ], + providers: [ + provideDefaultConfig({ + cmsComponents: { + RegisterCustomerWithOTPComponent: { + component: OneTimePasswordRegisterComponent, + guards: [NotAuthGuard], + providers: [ + { + provide: RegisterComponentService, + useClass: RegisterComponentService, + deps: [UserRegisterFacade, UntypedFormBuilder], + }, + ], + }, + }, + }), + ], + declarations: [OneTimePasswordRegisterComponent], +}) +export class OneTimePasswordRegisterModule {} diff --git a/feature-libs/user/profile/components/public_api.ts b/feature-libs/user/profile/components/public_api.ts index 4e9ca5c82f1..486cdfd79b6 100644 --- a/feature-libs/user/profile/components/public_api.ts +++ b/feature-libs/user/profile/components/public_api.ts @@ -13,3 +13,5 @@ export * from './update-password/index'; export * from './update-profile/index'; export * from './user-profile-components.module'; export * from './address-book/index'; +export * from './otp-login-register/index'; +export * from './registration-verification-token-form/index'; diff --git a/feature-libs/user/profile/components/registration-verification-token-form/index.ts b/feature-libs/user/profile/components/registration-verification-token-form/index.ts new file mode 100644 index 00000000000..48c6a2893ef --- /dev/null +++ b/feature-libs/user/profile/components/registration-verification-token-form/index.ts @@ -0,0 +1,9 @@ +/* + * SPDX-FileCopyrightText: 2025 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './verify-register-verification-token-form.module'; +export * from './verify-register-verification-token-form.component'; +export * from './verify-register-verification-token-form.service'; diff --git a/feature-libs/user/profile/components/registration-verification-token-form/verify-register-verification-token-form.component.html b/feature-libs/user/profile/components/registration-verification-token-form/verify-register-verification-token-form.component.html new file mode 100644 index 00000000000..add244d8c00 --- /dev/null +++ b/feature-libs/user/profile/components/registration-verification-token-form/verify-register-verification-token-form.component.html @@ -0,0 +1,156 @@ +
+ + + + + + +
+ +
+ +
+ +
+ +
+ + +
+
+ + + + + + +
+
diff --git a/feature-libs/user/profile/components/registration-verification-token-form/verify-register-verification-token-form.component.spec.ts b/feature-libs/user/profile/components/registration-verification-token-form/verify-register-verification-token-form.component.spec.ts new file mode 100644 index 00000000000..73b2ada1e8a --- /dev/null +++ b/feature-libs/user/profile/components/registration-verification-token-form/verify-register-verification-token-form.component.spec.ts @@ -0,0 +1,246 @@ +/* + * SPDX-FileCopyrightText: 2025 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ +import { ChangeDetectorRef, Pipe, PipeTransform } from '@angular/core'; +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { + ReactiveFormsModule, + UntypedFormControl, + UntypedFormGroup, +} from '@angular/forms'; +import { RouterTestingModule } from '@angular/router/testing'; +import { + FeatureConfigService, + I18nTestingModule, + RoutingService, +} from '@spartacus/core'; +import { + FormErrorsModule, + LaunchDialogService, + SpinnerModule, +} from '@spartacus/storefront'; +import { BehaviorSubject, of } from 'rxjs'; +import createSpy = jasmine.createSpy; +import { RegistrationVerificationTokenFormComponentService } from './verify-register-verification-token-form.service'; +import { RegistrationVerificationTokenFormComponent } from './verify-register-verification-token-form.component'; + +const mockRegisterFormData: any = { + titleCode: 'Mr', + firstName: 'John', + lastName: 'Doe', + email: 'JohnDoe@thebest.john.intheworld.com', + tokenId: 'mock_tokenId', + tokenCode: 'mock_tokenCode', + password: 'strongPass$!123', + passwordconf: 'strongPass$!123', +}; + +class MockRoutingService { + go = createSpy(); +} + +class MockFormComponentService + implements Partial +{ + form: UntypedFormGroup = new UntypedFormGroup({ + tokenId: new UntypedFormControl(), + tokenCode: new UntypedFormControl(), + }); + isUpdating$ = new BehaviorSubject(false); + register = createSpy().and.stub(); + createVerificationToken = createSpy().and.returnValue( + of({ tokenId: 'testTokenId', expiresIn: '300' }) + ); + displayMessage = createSpy('displayMessage').and.stub(); +} +@Pipe({ + name: 'cxUrl', +}) +class MockUrlPipe implements PipeTransform { + transform() {} +} + +class MockLaunchDialogService implements Partial { + openDialogAndSubscribe = createSpy().and.stub(); +} + +class MockRegistrationVerificationTokenFormComponentService + implements Partial +{ + register = createSpy().and.returnValue(of(mockRegisterFormData)); + postRegisterMessage = createSpy(); + displayMessage = createSpy(); +} + +describe('RegistrationVerificationTokenFormComponent', () => { + let component: RegistrationVerificationTokenFormComponent; + let fixture: ComponentFixture; + let service: RegistrationVerificationTokenFormComponentService; + let launchDialogService: LaunchDialogService; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + ReactiveFormsModule, + RouterTestingModule, + I18nTestingModule, + FormErrorsModule, + SpinnerModule, + ], + declarations: [RegistrationVerificationTokenFormComponent, MockUrlPipe], + providers: [ + { + provide: RegistrationVerificationTokenFormComponentService, + useClass: MockFormComponentService, + }, + { + provide: LaunchDialogService, + useClass: MockLaunchDialogService, + }, + { + provide: RoutingService, + useClass: MockRoutingService, + }, + { + provide: RegistrationVerificationTokenFormComponentService, + useClass: MockRegistrationVerificationTokenFormComponentService, + }, + ChangeDetectorRef, + ], + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent( + RegistrationVerificationTokenFormComponent + ); + service = TestBed.inject(RegistrationVerificationTokenFormComponentService); + launchDialogService = TestBed.inject(LaunchDialogService); + component = fixture.componentInstance; + fixture.detectChanges(); + history.pushState( + { + tokenId: 'mock_tokenId', + loginId: 'JohnDoe@thebest.john.intheworld.com', + titleCode: 'Mr', + firstName: 'John', + lastName: 'Doe', + }, + '' + ); + }); + + it('should create component', () => { + expect(component).toBeTruthy(); + }); + + describe('register', () => { + it('should register with valid form', () => { + component.registerForm.patchValue(mockRegisterFormData); + component.ngOnInit(); + component.onSubmit(); + expect(service.register).toHaveBeenCalledWith({ + firstName: mockRegisterFormData.firstName, + lastName: mockRegisterFormData.lastName, + uid: mockRegisterFormData.email.toLowerCase(), + password: mockRegisterFormData.password, + titleCode: mockRegisterFormData.titleCode, + verificationTokenId: mockRegisterFormData.tokenId, + verificationTokenCode: mockRegisterFormData.tokenCode, + }); + }); + + it('should not register with valid form', () => { + component.ngOnInit(); + component.onSubmit(); + expect(service.register).not.toHaveBeenCalled(); + }); + + it('should display info dialog', () => { + component.openInfoDailog(); + expect(launchDialogService.openDialogAndSubscribe).toHaveBeenCalled(); + }); + + it('should resend OTP', () => { + component.target = 'example@example.com'; + spyOn(component, 'startWaitTimeInterval'); + spyOn(component, 'createRegistrationVerificationToken').and.returnValue( + of({ tokenId: 'mock_tokenId', expiresIn: '300' }) + ); + + component.resendOTP(); + + expect(component.isResendDisabled).toBe(true); + expect(component.waitTime).toBe(60); + expect(component.startWaitTimeInterval).toHaveBeenCalled(); + expect(component.createRegistrationVerificationToken).toHaveBeenCalled(); + expect(service.displayMessage).toHaveBeenCalledWith( + 'verificationTokenForm.createVerificationToken', + { target: 'example@example.com' } + ); + }); + }); + + describe('password validators', () => { + let featureConfigService: FeatureConfigService; + + it('should have new validators when feature flag is enabled', () => { + featureConfigService = TestBed.inject(FeatureConfigService); + spyOn(featureConfigService, 'isEnabled').and.returnValue(true); + + fixture = TestBed.createComponent( + RegistrationVerificationTokenFormComponent + ); + component = fixture.componentInstance; + + fixture.detectChanges(); + + const passwordControl = component.registerForm.get( + 'password' + ) as UntypedFormControl; + const validators = passwordControl.validator + ? passwordControl.validator({} as any) + : []; + + expect(passwordControl).toBeTruthy(); + expect(validators).toEqual({ + required: true, + cxMinOneDigit: true, + cxMinOneSpecialCharacter: true, + cxMinOneUpperCaseCharacter: true, + cxMinEightCharactersLength: true, + cxMaxCharactersLength: true, + }); + }); + + it('should have old validators when feature flag is not enabled', () => { + featureConfigService = TestBed.inject(FeatureConfigService); + spyOn(featureConfigService, 'isEnabled').and.returnValue(false); + + fixture = TestBed.createComponent( + RegistrationVerificationTokenFormComponent + ); + component = fixture.componentInstance; + + fixture.detectChanges(); + + const passwordControl = component.registerForm.get( + 'password' + ) as UntypedFormControl; + const validators = passwordControl.validator + ? passwordControl.validator({} as any) + : []; + + expect(passwordControl).toBeTruthy(); + expect(validators).toEqual({ + cxMinOneDigit: true, + cxMinOneSpecialCharacter: true, + cxMinOneUpperCaseCharacter: true, + cxMinSixCharactersLength: true, + required: true, + }); + }); + }); +}); diff --git a/feature-libs/user/profile/components/registration-verification-token-form/verify-register-verification-token-form.component.ts b/feature-libs/user/profile/components/registration-verification-token-form/verify-register-verification-token-form.component.ts new file mode 100644 index 00000000000..38eb2764f98 --- /dev/null +++ b/feature-libs/user/profile/components/registration-verification-token-form/verify-register-verification-token-form.component.ts @@ -0,0 +1,292 @@ +/* + * SPDX-FileCopyrightText: 2025 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + ElementRef, + OnInit, + ViewChild, + inject, +} from '@angular/core'; +import { + UntypedFormBuilder, + UntypedFormGroup, + Validators, +} from '@angular/forms'; +import { + CustomFormValidators, + LAUNCH_CALLER, + LaunchDialogService, +} from '@spartacus/storefront'; +import { BehaviorSubject } from 'rxjs'; +import { ONE_TIME_PASSWORD_REGISTRATION_PURPOSE } from '../user-account-constants'; +import { RegistrationVerificationTokenFormComponentService } from './verify-register-verification-token-form.service'; +import { + AuthConfigService, + FeatureConfigService, + OAuthFlow, + RoutingService, +} from '@spartacus/core'; +import { UserSignUp } from '@spartacus/user/profile/root'; +import { HttpErrorResponse } from '@angular/common/http'; +import { + VerificationToken, + VerificationTokenFacade, +} from '@spartacus/user/account/root'; + +@Component({ + selector: 'cx-registration-verification-token-form', + templateUrl: './verify-register-verification-token-form.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class RegistrationVerificationTokenFormComponent implements OnInit { + protected fb = inject(UntypedFormBuilder); + protected router = inject(RoutingService); + protected authConfigService = inject(AuthConfigService); + protected registrationVerificationTokenFacade = inject( + VerificationTokenFacade + ); + protected service: RegistrationVerificationTokenFormComponentService = inject( + RegistrationVerificationTokenFormComponentService + ); + + protected launchDialogService: LaunchDialogService = + inject(LaunchDialogService); + + private featureConfigService = inject(FeatureConfigService); + protected passwordValidators = this.getPasswordValidators(); + + getPasswordValidators(): any { + if (this.featureConfigService?.isEnabled('formErrorsDescriptiveMessages')) { + if ( + this.featureConfigService.isEnabled('enableSecurePasswordValidation') + ) { + return CustomFormValidators.securePasswordValidators; + } else { + if ( + this.featureConfigService.isEnabled( + 'enableConsecutiveCharactersPasswordRequirement' + ) + ) { + return [ + ...CustomFormValidators.passwordValidators, + CustomFormValidators.noConsecutiveCharacters, + ]; + } else { + return CustomFormValidators.passwordValidators; + } + } + } else { + if ( + this.featureConfigService.isEnabled('enableSecurePasswordValidation') + ) { + return CustomFormValidators.securePasswordValidator; + } else { + return CustomFormValidators.passwordValidators; + } + } + } + + protected cdr: ChangeDetectorRef = inject(ChangeDetectorRef); + + waitTime: number = 60; + + registerForm: UntypedFormGroup = this.fb.group( + { + password: ['', [Validators.required, ...this.passwordValidators]], + passwordconf: ['', Validators.required], + tokenCode: ['', Validators.required], + titleCode: [''], + firstName: [''], + lastName: [''], + email: [''], + tokenId: [''], + }, + { + validators: CustomFormValidators.passwordsMustMatch( + 'password', + 'passwordconf' + ), + } + ); + + ngOnInit() { + if (!!history.state) { + this.tokenId = history.state['tokenId']; + this.target = history.state['loginId']; + this.titleCode = history.state['titleCode']; + this.firstName = history.state['firstName']; + this.lastName = history.state['lastName']; + + history.pushState( + { + tokenId: '', + loginId: '', + titleCode: '', + firstName: '', + lastName: '', + }, + 'verifyTokenForRegistration' + ); + } + + if (!this.target || !this.tokenId || !this.firstName || !this.lastName) { + this.router.go(['/login/register']); + } else { + this.startWaitTimeInterval(); + this.service.displayMessage( + 'verificationTokenForm.createVerificationToken', + { target: this.target } + ); + } + } + + isLoading$ = new BehaviorSubject(false); + + @ViewChild('noReceiveCodeLink') element: ElementRef; + + @ViewChild('resendLink') resendLink: ElementRef; + + tokenId: string; + + tokenCode: string; + + target: string; + + titleCode: string; + + firstName: string; + + lastName: string; + + password: string; + + passwordconf: string; + + isResendDisabled: boolean = true; + + onSubmit(): void { + if (this.registerForm.valid) { + this.registerUserWithVerificationToken(); + } else { + this.registerForm.markAllAsTouched(); + } + } + + registerUserWithVerificationToken(): void { + this.isLoading$.next(true); + this.registerForm.setValue({ + titleCode: this.titleCode, + firstName: this.firstName, + lastName: this.lastName, + email: this.target, + tokenId: this.tokenId, + password: this.registerForm.value.password, + tokenCode: this.registerForm.value.tokenCode, + passwordconf: this.registerForm.value.passwordconf, + }); + this.service + .register(this.collectDataFromRegisterForm(this.registerForm.value)) + .subscribe({ + next: () => this.onRegisterUserSuccess(), + complete: () => this.isLoading$.next(false), + error: (error: HttpErrorResponse) => { + if (error.status === 400) { + this.registerForm + .get('tokenCode') + ?.setErrors({ invalidTokenCodeError: error.message }); + } + this.isLoading$.next(false); + }, + }); + } + + collectDataFromRegisterForm(formData: any): UserSignUp { + const { + email, + firstName, + lastName, + password, + titleCode, + tokenId: verificationTokenId, + tokenCode: verificationTokenCode, + } = formData; + + return { + uid: email.toLowerCase(), + firstName, + lastName, + password, + titleCode, + verificationTokenId, + verificationTokenCode, + }; + } + + protected onRegisterUserSuccess(): void { + if ( + this.authConfigService.getOAuthFlow() === + OAuthFlow.ResourceOwnerPasswordFlow + ) { + this.router.go('login'); + } + this.service.postRegisterMessage(); + } + + resendOTP(): void { + this.isResendDisabled = true; + this.resendLink.nativeElement.tabIndex = -1; + this.waitTime = 60; + this.startWaitTimeInterval(); + this.createRegistrationVerificationToken( + this.target, + ONE_TIME_PASSWORD_REGISTRATION_PURPOSE + ).subscribe({ + next: (result: VerificationToken) => (this.tokenId = result.tokenId), + complete: () => + this.service.displayMessage( + 'verificationTokenForm.createVerificationToken', + { target: this.target } + ), + }); + } + + createRegistrationVerificationToken(loginId: string, purpose: string) { + return this.registrationVerificationTokenFacade.createVerificationToken({ + loginId, + purpose, + }); + } + + startWaitTimeInterval(): void { + const interval = setInterval(() => { + this.waitTime--; + this.cdr.detectChanges(); + if (this.waitTime <= 0) { + clearInterval(interval); + this.isResendDisabled = false; + this.resendLink.nativeElement.tabIndex = 0; + this.cdr.detectChanges(); + } + }, 1000); + } + + openInfoDailog(): void { + this.launchDialogService.openDialogAndSubscribe( + LAUNCH_CALLER.ACCOUNT_VERIFICATION_TOKEN, + this.element + ); + } + + onOpenInfoDailogKeyDown(event: KeyboardEvent) { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + this.openInfoDailog(); + } + } +} diff --git a/feature-libs/user/profile/components/registration-verification-token-form/verify-register-verification-token-form.module.ts b/feature-libs/user/profile/components/registration-verification-token-form/verify-register-verification-token-form.module.ts new file mode 100644 index 00000000000..600975132d3 --- /dev/null +++ b/feature-libs/user/profile/components/registration-verification-token-form/verify-register-verification-token-form.module.ts @@ -0,0 +1,63 @@ +/* + * SPDX-FileCopyrightText: 2025 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { RouterModule } from '@angular/router'; +import { + CmsConfig, + FeaturesConfigModule, + GlobalMessageService, + I18nModule, + NotAuthGuard, + UrlModule, + provideDefaultConfig, +} from '@spartacus/core'; +import { + FormErrorsModule, + IconModule, + KeyboardFocusModule, + SpinnerModule, +} from '@spartacus/storefront'; +import { RegistrationVerificationTokenFormComponent } from './verify-register-verification-token-form.component'; +import { RegistrationVerificationTokenFormComponentService } from './verify-register-verification-token-form.service'; +import { UserRegisterFacade } from '@spartacus/user/profile/root'; + +@NgModule({ + imports: [ + CommonModule, + FormsModule, + KeyboardFocusModule, + ReactiveFormsModule, + RouterModule, + UrlModule, + IconModule, + I18nModule, + FormErrorsModule, + SpinnerModule, + FeaturesConfigModule, + ], + providers: [ + provideDefaultConfig({ + cmsComponents: { + VerifyOTPForRegistrationComponent: { + component: RegistrationVerificationTokenFormComponent, + guards: [NotAuthGuard], + providers: [ + { + provide: RegistrationVerificationTokenFormComponentService, + useClass: RegistrationVerificationTokenFormComponentService, + deps: [GlobalMessageService, UserRegisterFacade], + }, + ], + }, + }, + }), + ], + declarations: [RegistrationVerificationTokenFormComponent], +}) +export class RegistrationVerificationTokenFormModule {} diff --git a/feature-libs/user/profile/components/registration-verification-token-form/verify-register-verification-token-form.service.spec.ts b/feature-libs/user/profile/components/registration-verification-token-form/verify-register-verification-token-form.service.spec.ts new file mode 100644 index 00000000000..547c7b75c4e --- /dev/null +++ b/feature-libs/user/profile/components/registration-verification-token-form/verify-register-verification-token-form.service.spec.ts @@ -0,0 +1,82 @@ +/* + * SPDX-FileCopyrightText: 2025 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ +import { inject, TestBed } from '@angular/core/testing'; +import { UntypedFormBuilder } from '@angular/forms'; +import { FeatureConfigService, GlobalMessageService } from '@spartacus/core'; +import { UserRegisterFacade, UserSignUp } from '@spartacus/user/profile/root'; +import { of } from 'rxjs'; + +import createSpy = jasmine.createSpy; +import { RegistrationVerificationTokenFormComponentService } from './verify-register-verification-token-form.service'; + +class MockUserRegisterFacade implements Partial { + getTitles = createSpy().and.returnValue(of([])); + register = createSpy().and.callFake((user: any) => of(user)); +} +class MockGlobalMessageService implements Partial { + add = createSpy(); +} + +class MockFeatureConfigService { + isEnabled() { + return true; + } +} + +describe('RegistrationVerificationTokenFormComponentService', () => { + let service: RegistrationVerificationTokenFormComponentService; + let userRegisterFacade: UserRegisterFacade; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + RegistrationVerificationTokenFormComponentService, + UntypedFormBuilder, + { provide: UserRegisterFacade, useClass: MockUserRegisterFacade }, + { provide: GlobalMessageService, useClass: MockGlobalMessageService }, + { provide: FeatureConfigService, useClass: MockFeatureConfigService }, + ], + }); + + userRegisterFacade = TestBed.inject(UserRegisterFacade); + service = TestBed.inject(RegistrationVerificationTokenFormComponentService); + }); + + it('should inject RegistrationVerificationTokenFormComponentService', inject( + [RegistrationVerificationTokenFormComponentService], + ( + registrationVerificationTokenFormComponentService: RegistrationVerificationTokenFormComponentService + ) => { + expect(registrationVerificationTokenFormComponentService).toBeTruthy(); + } + )); + + it('should be able to register user from UserRegisterService', () => { + const userRegisterFormData: UserSignUp = { + titleCode: 'Mr.', + firstName: 'firstName', + lastName: 'lastName', + uid: 'uid', + password: 'password', + }; + service.register(userRegisterFormData); + expect(userRegisterFacade.register).toHaveBeenCalledWith({ + titleCode: 'Mr.', + firstName: 'firstName', + lastName: 'lastName', + uid: 'uid', + password: 'password', + }); + }); + + describe('postRegisterMessage', () => { + it('should delegate to displayMessage', () => { + const displayMessageSpy = spyOn(service, 'displayMessage'); + service.postRegisterMessage(); + expect(displayMessageSpy).toHaveBeenCalled(); + }); + }); +}); diff --git a/feature-libs/user/profile/components/registration-verification-token-form/verify-register-verification-token-form.service.ts b/feature-libs/user/profile/components/registration-verification-token-form/verify-register-verification-token-form.service.ts new file mode 100644 index 00000000000..a28df21571b --- /dev/null +++ b/feature-libs/user/profile/components/registration-verification-token-form/verify-register-verification-token-form.service.ts @@ -0,0 +1,53 @@ +/* + * SPDX-FileCopyrightText: 2025 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Injectable, inject } from '@angular/core'; +import { + FeatureConfigService, + GlobalMessageService, + GlobalMessageType, + User, +} from '@spartacus/core'; +import { UserRegisterFacade, UserSignUp } from '@spartacus/user/profile/root'; +import { Observable } from 'rxjs'; + +const globalMsgShowTime: number = 10000; +@Injectable() +export class RegistrationVerificationTokenFormComponentService { + protected globalMessageService = inject(GlobalMessageService); + protected userRegisterFacade = inject(UserRegisterFacade); + private featureConfigService: FeatureConfigService = + inject(FeatureConfigService); + + displayMessage(key: string, params: Object) { + this.globalMessageService.add( + { + key: key, + params, + }, + GlobalMessageType.MSG_TYPE_CONFIRMATION, + globalMsgShowTime + ); + } + + register(user: UserSignUp): Observable { + return this.userRegisterFacade.register(user); + } + + postRegisterMessage(): void { + if (this.featureConfigService.isEnabled('a11yPostRegisterSuccessMessage')) { + this.displayMessage( + 'register.postRegisterSuccessMessage', + globalMsgShowTime + ); + } else { + this.globalMessageService.add( + { key: 'register.postRegisterMessage' }, + GlobalMessageType.MSG_TYPE_CONFIRMATION + ); + } + } +} diff --git a/feature-libs/user/profile/components/user-account-constants.ts b/feature-libs/user/profile/components/user-account-constants.ts new file mode 100644 index 00000000000..ce0691639d0 --- /dev/null +++ b/feature-libs/user/profile/components/user-account-constants.ts @@ -0,0 +1,7 @@ +/* + * SPDX-FileCopyrightText: 2025 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +export const ONE_TIME_PASSWORD_REGISTRATION_PURPOSE = 'REGISTRATION'; diff --git a/feature-libs/user/profile/components/user-profile-components.module.ts b/feature-libs/user/profile/components/user-profile-components.module.ts index 86a74b75816..98c57275673 100644 --- a/feature-libs/user/profile/components/user-profile-components.module.ts +++ b/feature-libs/user/profile/components/user-profile-components.module.ts @@ -13,6 +13,8 @@ import { UpdateEmailModule } from './update-email/update-email.module'; import { UpdatePasswordModule } from './update-password/update-password.module'; import { UpdateProfileModule } from './update-profile/update-profile.module'; import { AddressBookModule } from './address-book'; +import { OneTimePasswordRegisterModule } from './otp-login-register'; +import { RegistrationVerificationTokenFormModule } from './registration-verification-token-form'; @NgModule({ imports: [ @@ -24,6 +26,8 @@ import { AddressBookModule } from './address-book'; ResetPasswordModule, CloseAccountModule, AddressBookModule, + OneTimePasswordRegisterModule, + RegistrationVerificationTokenFormModule, ], }) export class UserProfileComponentsModule {} diff --git a/feature-libs/user/profile/root/model/user-profile.model.ts b/feature-libs/user/profile/root/model/user-profile.model.ts index 47369186e92..a760271c364 100644 --- a/feature-libs/user/profile/root/model/user-profile.model.ts +++ b/feature-libs/user/profile/root/model/user-profile.model.ts @@ -36,4 +36,6 @@ export interface UserSignUp { password?: string; titleCode?: string; uid?: string; + verificationTokenId?: string; + verificationTokenCode?: string; } diff --git a/feature-libs/user/profile/root/user-profile-root.module.ts b/feature-libs/user/profile/root/user-profile-root.module.ts index 83fe1104131..e087d22494c 100644 --- a/feature-libs/user/profile/root/user-profile-root.module.ts +++ b/feature-libs/user/profile/root/user-profile-root.module.ts @@ -30,6 +30,8 @@ export function defaultUserProfileComponentsConfig(): CmsConfig { 'ResetPasswordComponent', 'CloseAccountComponent', 'AccountAddressBookComponent', + 'RegisterCustomerWithOTPComponent', + 'VerifyOTPForRegistrationComponent', ], }, // by default core is bundled together with components diff --git a/feature-libs/user/profile/styles/_index.scss b/feature-libs/user/profile/styles/_index.scss index be059edd6c7..f939dafced0 100644 --- a/feature-libs/user/profile/styles/_index.scss +++ b/feature-libs/user/profile/styles/_index.scss @@ -9,3 +9,5 @@ @import './address-book'; @import './address-form'; @import './suggested-addresses-dialog'; +@import './otp-register-form'; +@import './verification-token-form'; diff --git a/feature-libs/user/profile/styles/_otp-register-form.scss b/feature-libs/user/profile/styles/_otp-register-form.scss new file mode 100644 index 00000000000..22a1f6fee93 --- /dev/null +++ b/feature-libs/user/profile/styles/_otp-register-form.scss @@ -0,0 +1,21 @@ +%cx-otp-register-form { + form { + a { + text-decoration: underline; + } + .cx-login-link { + margin: 1rem 0 0; + } + } + .cx-page-section { + padding-top: 0.3125rem; + } + .label-content { + font-family: 'Open Sans', sans-serif; + font-size: 1rem; + font-weight: 600; + line-height: 1.361875rem; + text-underline-position: from-font; + text-decoration-skip-ink: none; + } +} diff --git a/feature-libs/user/profile/styles/_verification-token-form.scss b/feature-libs/user/profile/styles/_verification-token-form.scss new file mode 100644 index 00000000000..ae63bea99cd --- /dev/null +++ b/feature-libs/user/profile/styles/_verification-token-form.scss @@ -0,0 +1,60 @@ +%cx-registration-verification-token-form { + --cx-max-width: 50%; + .resend-link-text { + display: flex; + flex-direction: row; + width: 100%; + margin: auto; + + .left-text { + padding: 0; + width: 50%; + text-align: start; + } + + .right-text { + padding: 0; + width: 50%; + text-align: end; + } + + a.disabled-link { + pointer-events: none; + color: var(--cx-color-dark); + } + + a { + color: var(--cx-color-primary); + @include type(7); + } + } + + .verify-container { + width: 100%; + margin-top: 2.5rem; + } + + .input-hint { + font-size: 0.875rem; + color: var(--cx-color-secondary); + margin-bottom: 0; + } + + cx-spinner { + display: none; + } + + button { + flex: 100%; + } + + .label-content { + font-family: 'Open Sans', sans-serif; + font-size: 1rem; + font-weight: 600; + line-height: 1.361875rem; + margin-top: 1.25rem; + text-underline-position: from-font; + text-decoration-skip-ink: none; + } +} diff --git a/projects/assets/src/translations/en/common.json b/projects/assets/src/translations/en/common.json index a96f9e87010..0fb608deda4 100644 --- a/projects/assets/src/translations/en/common.json +++ b/projects/assets/src/translations/en/common.json @@ -186,6 +186,7 @@ "cxMaxCharactersLength": "Password cannot have more than 128 characters", "cxContainsSpecialCharacters": "Password cannot contain special characters", "cxNoConsecutiveCharacters": "Password cannot contain consecutive identical characters", + "invalidTokenCodeError": "This code is not valid", "date": { "required": "Field {{label}} is required", "min": "Field {{label}} cannot be before {{min}}", diff --git a/projects/storefrontapp-e2e-cypress/cypress/e2e/accessibility/otp-registration-tabbing.e2e-spec-flaky.cy.ts b/projects/storefrontapp-e2e-cypress/cypress/e2e/accessibility/otp-registration-tabbing.e2e-spec-flaky.cy.ts new file mode 100644 index 00000000000..0a1e4cca67b --- /dev/null +++ b/projects/storefrontapp-e2e-cypress/cypress/e2e/accessibility/otp-registration-tabbing.e2e-spec-flaky.cy.ts @@ -0,0 +1,40 @@ +/* + * SPDX-FileCopyrightText: 2025 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ +import { verifyTabbingOrder } from '../../helpers/accessibility/tabbing-order'; +import { tabbingOrderConfig as config } from '../../helpers/accessibility/tabbing-order.config'; + +describe('Tabbing order for B2C OTP registration', () => { + before(() => { + cy.window().then((win) => win.sessionStorage.clear()); + }); + + describe('B2C OTP registration', () => { + context('B2C OTP registration page', () => { + beforeEach(() => { + cy.visit('/login/register'); + cy.get('cx-otp-register-form').should('exist'); + cy.get('cx-otp-register-form form').should('exist'); + }); + + it('should allow to navigate with tab key for otp registration form(filled out form) (CXSPA-3919)', () => { + cy.get('[formcontrolname="titleCode"]').ngSelect('Mr.'); + cy.get('[formcontrolname="firstName"]').type('John'); + cy.get('[formcontrolname="lastName"]').type('Doe'); + cy.get('[formcontrolname="email"]').type('customer.test@sap.com'); + cy.get('[formcontrolname="newsletter"]').check(); + cy.get('[formcontrolname="termsandconditions"]').check(); + + verifyTabbingOrder('cx-otp-register-form', config.otpRegistration); + cy.get('button[type=submit]').click(); + cy.get('cx-registration-verification-token-form').should('exist'); + verifyTabbingOrder( + 'cx-registration-verification-token-form', + config.verifyTokenForRegistration + ); + }); + }); + }); +}); diff --git a/projects/storefrontapp-e2e-cypress/cypress/e2e/regression/user_access/otp-registration.e2e-flaky.cy.ts b/projects/storefrontapp-e2e-cypress/cypress/e2e/regression/user_access/otp-registration.e2e-flaky.cy.ts new file mode 100644 index 00000000000..38e87c37d7f --- /dev/null +++ b/projects/storefrontapp-e2e-cypress/cypress/e2e/regression/user_access/otp-registration.e2e-flaky.cy.ts @@ -0,0 +1,153 @@ +/* + * SPDX-FileCopyrightText: 2025 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as login from '../../../helpers/login'; +import { viewportContext } from '../../../helpers/viewport-context'; +import { user } from '../../../sample-data/checkout-flow'; + +export function listenForUserRegistrationVerficationCodeEmailReceive( + customerEmail: string +) { + const mailCCV2Url = + Cypress.env('MAIL_CCV2_URL') + + Cypress.env('MAIL_CCV2_PREFIX') + + '/search?query=' + + customerEmail + + '&kind=to'; + + cy.request({ + method: 'GET', + url: mailCCV2Url, + }).then((response) => { + if (response.body.total != 1) { + listenForUserRegistrationVerficationCodeEmailReceive(customerEmail); + } + }); +} + +describe('OTP Registration', () => { + viewportContext(['mobile'], () => { + describe('B2C Customer Registration With OTP', () => { + beforeEach(() => { + cy.visit('/login/register'); + }); + + it('should be able to create b2c customer with otp (CXSPA-3919)', () => { + cy.log(`create verification token from the register form`); + cy.get('cx-otp-register-form form').within(() => { + cy.get('ng-select[formcontrolname="titleCode"]') + .click() + .get('div.ng-option') + .contains('Mr') + .click(); + cy.get('[formcontrolname="firstName"]').clear().type(user.firstName); + cy.get('[formcontrolname="lastName"]').clear().type(user.lastName); + cy.get('[formcontrolname="email"]').clear().type(user.email); + cy.get('[formcontrolname="termsandconditions"]').click(); + cy.get('button[type=submit]').click(); + }); + + cy.get('cx-registration-verification-token-form').should('exist'); + cy.get('cx-registration-verification-token-form').should('be.visible'); + + listenForUserRegistrationVerficationCodeEmailReceive(user.email); + + const mailCCV2Url = + Cypress.env('MAIL_CCV2_URL') + + Cypress.env('MAIL_CCV2_PREFIX') + + '/search?query=' + + user.email + + '&kind=to&start=0&limit=1'; + + cy.request({ + method: 'GET', + url: mailCCV2Url, + }).then((response) => { + const verificationCodeEmailStartText = + 'Please use the following verification code to register in Spartacus Electronics Site:

'; + const lableP = '

'; + + const items = response.body.items; + const emailBody = items[0].Content.Body; + + const verificationCodeEmailStartIndex = + emailBody.indexOf(verificationCodeEmailStartText) + + verificationCodeEmailStartText.length; + const verificationCodeStartIndex = + emailBody.indexOf(lableP, verificationCodeEmailStartIndex) + + lableP.length; + const verificationCode = emailBody.substring( + verificationCodeStartIndex, + verificationCodeStartIndex + 8 + ); + cy.log('Extracted verification code: ' + verificationCode); + + login.listenForTokenAuthenticationRequest(); + cy.get('cx-registration-verification-token-form').within(() => { + cy.get('[formcontrolname="tokenCode"]') + .clear() + .type(verificationCode); + cy.get('[formcontrolname="password"]').clear().type(user.password); + cy.get('[formcontrolname="passwordconf"]') + .clear() + .type(user.password); + cy.get('button[type=submit]').click(); + }); + cy.get('cx-login').should('exist'); + }); + }); + it('should not be able to register customer with invalid verification code (CXSPA-3919)', () => { + cy.log(`create verification token from the register form`); + cy.get('cx-otp-register-form form').within(() => { + cy.get('ng-select[formcontrolname="titleCode"]') + .click() + .get('div.ng-option') + .contains('Mr') + .click(); + cy.get('[formcontrolname="firstName"]').clear().type(user.firstName); + cy.get('[formcontrolname="lastName"]').clear().type(user.lastName); + cy.get('[formcontrolname="email"]').clear().type(user.email); + cy.get('[formcontrolname="termsandconditions"]').click(); + cy.get('button[type=submit]').click(); + }); + + const verificationCode = 'invalidCode'; + + cy.get('cx-registration-verification-token-form').should('exist'); + cy.get('cx-registration-verification-token-form').should('be.visible'); + cy.get('cx-registration-verification-token-form').within(() => { + cy.get('[formcontrolname="tokenCode"]') + .clear() + .type(verificationCode); + cy.get('[formcontrolname="password"]').clear().type(user.password); + cy.get('[formcontrolname="passwordconf"]') + .clear() + .type(user.password); + cy.get('button[type=submit]').click(); + }); + cy.get('cx-form-errors') + .should('be.visible') + .and('contain.text', 'This code is not valid'); + }); + }); + + describe('Verification token', () => { + beforeEach(() => { + cy.visit('/register/verify-token'); + }); + it('Should go back to register page when click back button (CXSPA-3919)', () => { + cy.get('cx-registration-verification-token-form').should('exist'); + + cy.get('cx-registration-verification-token-form form').within(() => { + cy.get('div.verify-container button').contains('Back').click(); + }); + + cy.get('cx-registration-verification-token-form').should('not.exist'); + cy.get('cx-otp-register-form form').should('exist'); + }); + }); + }); +}); diff --git a/projects/storefrontapp-e2e-cypress/cypress/helpers/accessibility/tabbing-order.config.ts b/projects/storefrontapp-e2e-cypress/cypress/helpers/accessibility/tabbing-order.config.ts index 65d3656ce19..9ec9218b03d 100644 --- a/projects/storefrontapp-e2e-cypress/cypress/helpers/accessibility/tabbing-order.config.ts +++ b/projects/storefrontapp-e2e-cypress/cypress/helpers/accessibility/tabbing-order.config.ts @@ -2551,4 +2551,23 @@ export const tabbingOrderConfig: TabbingOrderConfig = { { value: 'Verify', type: TabbingOrderTypes.BUTTON }, { value: 'Back', type: TabbingOrderTypes.LINK }, ], + otpRegistration: [ + { type: TabbingOrderTypes.NG_SELECT }, + { type: TabbingOrderTypes.FORM_FIELD }, + { type: TabbingOrderTypes.FORM_FIELD }, + { type: TabbingOrderTypes.FORM_FIELD }, + { type: TabbingOrderTypes.GENERIC_CHECKBOX }, + { type: TabbingOrderTypes.GENERIC_CHECKBOX }, + { type: TabbingOrderTypes.LINK }, + { type: TabbingOrderTypes.BUTTON }, + { type: TabbingOrderTypes.LINK }, + ], + verifyTokenForRegistration: [ + { type: TabbingOrderTypes.FORM_FIELD }, + { type: TabbingOrderTypes.LINK }, + { type: TabbingOrderTypes.FORM_FIELD }, + { type: TabbingOrderTypes.FORM_FIELD }, + { type: TabbingOrderTypes.BUTTON }, + { type: TabbingOrderTypes.BUTTON }, + ], }; diff --git a/projects/storefrontapp-e2e-cypress/cypress/helpers/register.ts b/projects/storefrontapp-e2e-cypress/cypress/helpers/register.ts index 049e8290386..17af2fce418 100644 --- a/projects/storefrontapp-e2e-cypress/cypress/helpers/register.ts +++ b/projects/storefrontapp-e2e-cypress/cypress/helpers/register.ts @@ -28,7 +28,7 @@ export function verifyGlobalMessageAfterRegistration() { alert.should( 'contain', - 'Successful Registration: Please log in with provided credentials' + 'Your account has been successfully created! Please log in with provided credentials' ); cy.location().should((location) => { expect(location.pathname).to.match(/\/login$/); diff --git a/projects/storefrontlib/cms-structure/routing/default-routing-config.ts b/projects/storefrontlib/cms-structure/routing/default-routing-config.ts index 53e032c45d1..ba7f9fd4896 100644 --- a/projects/storefrontlib/cms-structure/routing/default-routing-config.ts +++ b/projects/storefrontlib/cms-structure/routing/default-routing-config.ts @@ -21,6 +21,11 @@ export const defaultStorefrontRoutesConfig: RoutesConfig = { protected: false, authFlow: true, }, + verifyTokenForRegistration: { + paths: ['register/verify-token'], + protected: false, + authFlow: true, + }, register: { paths: ['login/register'], protected: false,