diff --git a/README.md b/README.md index 963d220b90..0f925e8f06 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ Mifos® X Web App is a modern single-page application (SPA) built on top of the - [Date Formats](#date-and-datetime-format-settings) - [Session & Performance](#session--performance-settings) - [Password Policy](#password-policy-settings) + - [Email Validation](#email-validation-settings) - [UI Display](#ui-display-settings) - [OAUTH](#oauth-settings) - [OIDC](#oidc-settings) @@ -380,6 +381,14 @@ MIFOS_MIN_PASSWORD_LENGTH=8 MIFOS_PASSWORD_REGEX=^(?=.*[A-Z])(?=.*[a-z])(?=.*\d).{8,50}$ ``` +#### Email Validation Settings + +| Variable | Description | Default Value | +| -------------------- | ----------------------------------------------------- | --------------------------------------------------------------------- | +| EXTERNAL_EMAIL_REGEX | Regex pattern used to validate the client email field | `^[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*\.[A-Za-z]{2,}$` | + +If `EXTERNAL_EMAIL_REGEX` is not set, or is not a valid regular expression, the application falls back to the default pattern above. The email field is optional; the pattern is only checked when a value is entered. + #### UI Display Settings | Variable | Description | Default Value | diff --git a/src/app/clients/client-stepper/client-general-step/client-general-step.component.html b/src/app/clients/client-stepper/client-general-step/client-general-step.component.html index 1a5f30468d..819e9f0eb9 100644 --- a/src/app/clients/client-stepper/client-general-step/client-general-step.component.html +++ b/src/app/clients/client-stepper/client-general-step/client-general-step.component.html @@ -230,9 +230,9 @@ {{ 'labels.inputs.Email Address' | translate }} - @if (createClientForm.controls.emailAddress.errors?.email) { + @if (createClientForm.controls.emailAddress.errors?.pattern) { - {{ 'error.Email not valid' | translate }} + {{ 'errors.validation.emailInvalid' | translate }} } diff --git a/src/app/clients/client-stepper/client-general-step/client-general-step.component.spec.ts b/src/app/clients/client-stepper/client-general-step/client-general-step.component.spec.ts index 246b9aaa04..940e03ae52 100644 --- a/src/app/clients/client-stepper/client-general-step/client-general-step.component.spec.ts +++ b/src/app/clients/client-stepper/client-general-step/client-general-step.component.spec.ts @@ -112,3 +112,76 @@ describe('ClientGeneralStepComponent WEB-1103 incorporation validity date', () = expect(incorpValidityControl().hasError('matDatepickerMin')).toBe(true); }); }); + +describe('ClientGeneralStepComponent WEB-748 email validation', () => { + let fixture: ComponentFixture; + let component: ClientGeneralStepComponent; + + const clientTemplate: any = { + officeOptions: [{ id: 1, name: 'Head Office' }], + staffOptions: [], + clientLegalFormOptions: [{ id: LegalFormId.PERSON, value: 'PERSON' }], + clientTypeOptions: [], + clientClassificationOptions: [], + clientNonPersonMainBusinessLineOptions: [], + clientNonPersonConstitutionOptions: [], + genderOptions: [], + savingProductOptions: [] + }; + + function emailControl() { + return component.createClientForm.get('emailAddress'); + } + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + ClientGeneralStepComponent, + TranslateModule.forRoot() + ], + providers: [ + provideNativeDateAdapter(), + provideNoopAnimations(), + { provide: CdkStepper, useValue: { next: jest.fn(), previous: jest.fn() } }, + { provide: Dates, useValue: { formatDate: jest.fn(() => '15 January 2026') } }, + { + provide: SettingsService, + useValue: { businessDate: new Date(2026, 0, 15), dateFormat: 'dd MMMM yyyy', language: { code: 'en-US' } } + }, + { provide: ClientsService, useValue: { getClientWithOfficeTemplate: jest.fn(() => of(clientTemplate)) } } + ] + }).compileComponents(); + + const faIconLibrary = TestBed.inject(FaIconLibrary); + faIconLibrary.addIcons( + ...Object.keys(solidIcons) + .filter((key) => key !== 'fas' && key !== 'prefix' && key.startsWith('fa')) + .map((icon) => (solidIcons as any)[icon]) + ); + + fixture = TestBed.createComponent(ClientGeneralStepComponent); + component = fixture.componentInstance; + component.clientTemplate = clientTemplate; + fixture.detectChanges(); + }); + + it('stays optional when left blank', () => { + emailControl().patchValue(''); + expect(emailControl().valid).toBe(true); + }); + + it('accepts a well-formed email', () => { + emailControl().patchValue('client@example.com'); + expect(emailControl().valid).toBe(true); + }); + + it('rejects a malformed email', () => { + emailControl().patchValue('not-an-email'); + expect(emailControl().hasError('pattern')).toBe(true); + }); + + it('rejects a double-dot domain', () => { + emailControl().patchValue('client@example..com'); + expect(emailControl().hasError('pattern')).toBe(true); + }); +}); diff --git a/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts b/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts index 7302b76d55..4377fcfdd5 100644 --- a/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts +++ b/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts @@ -24,6 +24,7 @@ import { ClientsService } from 'app/clients/clients.service'; import { Dates } from 'app/core/utils/dates'; import { LegalFormId } from 'app/clients/models/legal-form.enum'; import { ExternalNationalIdService } from 'app/clients/services/external-national-id.service'; +import { environment } from 'environments/environment'; /** Custom Services */ import { SettingsService } from 'app/settings/settings.service'; @@ -137,7 +138,7 @@ export class ClientGeneralStepComponent implements OnInit { mobileNo: [''], emailAddress: [ '', - Validators.email + Validators.pattern(environment.externalEmailRegex) ], dateOfBirth: [''], clientTypeId: [''], diff --git a/src/assets/env.js b/src/assets/env.js index fcb8706d9e..99f60360b8 100644 --- a/src/assets/env.js +++ b/src/assets/env.js @@ -124,6 +124,9 @@ window['env']['externalNationalIdSystemApiKey'] = ''; window['env']['externalNationalIdRegex'] = ''; + // Email format validation regex (optional override; leave empty to use the built-in default) + window['env']['externalEmailRegex'] = ''; + // Postal Code Lookup (auto-fill address from postal code via external API) // Set to 'true' to enable, 'false' (default) to disable window['env']['enablePostalCodeLookup'] = 'false'; diff --git a/src/assets/env.template.js b/src/assets/env.template.js index 4905ab6176..279b6e6fe7 100644 --- a/src/assets/env.template.js +++ b/src/assets/env.template.js @@ -139,6 +139,9 @@ window['env']['externalNationalIdSystemApiKey'] = '$EXTERNAL_NATIONAL_ID_SYSTEM_API_KEY'; window['env']['externalNationalIdRegex'] = '$EXTERNAL_NATIONAL_ID_REGEX'; + // Email format validation regex (optional override; leave unset to use the built-in default) + window['env']['externalEmailRegex'] = '$EXTERNAL_EMAIL_REGEX'; + // Client Address Location (latitude, longitude, and map) window['env']['enableClientAddressLocation'] = '$MIFOS_ENABLE_CLIENT_ADDRESS_LOCATION'; diff --git a/src/environments/environment.prod.ts b/src/environments/environment.prod.ts index 96fd5d4bf7..5dc3932220 100644 --- a/src/environments/environment.prod.ts +++ b/src/environments/environment.prod.ts @@ -17,6 +17,23 @@ const provider = loadedEnv['apiProvider']; const parsedMinLength = Number(loadedEnv.minPasswordLength); const resolvedMinPasswordLength = Number.isInteger(parsedMinLength) && parsedMinLength > 0 ? parsedMinLength : 8; +// Domain labels can't contain dots, so `example..com` can't slip through as a valid host. +const DEFAULT_EMAIL_REGEX = '^[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(?:\\.[A-Za-z0-9-]+)*\\.[A-Za-z]{2,}$'; +function isValidRegex(pattern: string): boolean { + try { + new RegExp(pattern); + return true; + } catch { + return false; + } +} +// Guards against a malformed override (e.g. an unbalanced `[`) throwing inside +// Validators.pattern and preventing the Create Client form from opening. +const resolvedEmailRegex = + loadedEnv['externalEmailRegex'] && isValidRegex(loadedEnv['externalEmailRegex']) + ? loadedEnv['externalEmailRegex'] + : DEFAULT_EMAIL_REGEX; + export const environment = { production: true, version: env.mifos_x.version, @@ -133,6 +150,9 @@ export const environment = { externalNationalIdSystemApiKey: loadedEnv['externalNationalIdSystemApiKey'] || '', externalNationalIdRegex: loadedEnv['externalNationalIdRegex'] || '', + /** Email format validation regex, overridable per deployment. */ + externalEmailRegex: resolvedEmailRegex, + /** * Hide client data information (mask client names with *) * Set via MIFOS_COMPLIANCE_HIDE_CLIENT_DATA env var diff --git a/src/environments/environment.ts b/src/environments/environment.ts index 73c5c4dbeb..98350cfaa0 100644 --- a/src/environments/environment.ts +++ b/src/environments/environment.ts @@ -20,6 +20,23 @@ const loadedEnv = window.env || {}; const parsedMinLength = Number(loadedEnv.minPasswordLength); const resolvedMinPasswordLength = Number.isInteger(parsedMinLength) && parsedMinLength > 0 ? parsedMinLength : 8; +// Domain labels can't contain dots, so `example..com` can't slip through as a valid host. +const DEFAULT_EMAIL_REGEX = '^[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(?:\\.[A-Za-z0-9-]+)*\\.[A-Za-z]{2,}$'; +function isValidRegex(pattern: string): boolean { + try { + new RegExp(pattern); + return true; + } catch { + return false; + } +} +// Guards against a malformed override (e.g. an unbalanced `[`) throwing inside +// Validators.pattern and preventing the Create Client form from opening. +const resolvedEmailRegex = + loadedEnv.externalEmailRegex && isValidRegex(loadedEnv.externalEmailRegex) + ? loadedEnv.externalEmailRegex + : DEFAULT_EMAIL_REGEX; + export const environment = { production: false, version: env.mifos_x.version, @@ -122,6 +139,9 @@ export const environment = { externalNationalIdSystemApiKey: loadedEnv.externalNationalIdSystemApiKey || '', externalNationalIdRegex: loadedEnv.externalNationalIdRegex || '', + /** Email format validation regex, overridable per deployment. */ + externalEmailRegex: resolvedEmailRegex, + /** * Postal Code Lookup — auto-fill city/state/country from postal code. * Uses external Zippopotam.us API. Disable for deployments with strict privacy requirements.