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,