From 1ea9f88bb2fb6317ecd021897e178bc41e370234 Mon Sep 17 00:00:00 2001 From: Shubham Kumar Date: Sat, 14 Feb 2026 01:10:19 +0530 Subject: [PATCH] WEB-600 feat:External National ID System integration for client creation/editing (#3145) improve --- README.md | 29 ++ angular.json | 3 +- docker-compose.external-nationalid.yml | 28 ++ docker-compose.yml | 2 - env.sample | 14 + nginx.conf.template | 59 ++++ proxy.conf.js | 32 ++ proxy.localhost.conf.js | 32 ++ .../client-general-step.component.html | 27 +- .../client-general-step.component.scss | 17 + .../client-general-step.component.ts | 4 + src/app/clients/clients.service.ts | 38 ++- .../edit-client/edit-client.component.html | 21 +- .../edit-client/edit-client.component.scss | 21 +- .../edit-client/edit-client.component.ts | 5 + .../external-national-id.constants.ts | 86 +++++ .../services/external-national-id.service.ts | 308 ++++++++++++++++++ src/assets/env.js | 10 + src/assets/env.template.js | 8 + src/assets/translations/en-US.json | 6 + src/environments/environment.prod.ts | 7 + src/environments/environment.ts | 11 + src/typings.d.ts | 5 + 23 files changed, 752 insertions(+), 21 deletions(-) create mode 100644 docker-compose.external-nationalid.yml create mode 100644 nginx.conf.template create mode 100644 src/app/clients/services/external-national-id.constants.ts create mode 100644 src/app/clients/services/external-national-id.service.ts diff --git a/README.md b/README.md index ed33d1816..69de6a2fd 100644 --- a/README.md +++ b/README.md @@ -313,6 +313,35 @@ Available languages: | FINERACT_PLUGIN_OIDC_API_URL | Set the Client API URL | | | FINERACT_PLUGIN_OIDC_FRONTEND_URL | Set the Front End URL callback | | +#### External National ID System Integration + +These variables enable automatic lookup and auto-fill of client data from an external National ID system (e.g., Mexico's CURP) during client creation and editing. + +| Variable | Description | Default Value | +| -------------------------------------- | ---------------------------------------------------------------------------- | ------------- | +| ENABLE_EXTERNAL_NATIONAL_ID_SYSTEM | Set to `true` to enable External National ID lookup | false | +| EXTERNAL_NATIONAL_ID_SYSTEM_URL | URL of the external National ID API | | +| EXTERNAL_NATIONAL_ID_SYSTEM_API_HEADER | Header name for the external API key (e.g., `X-Gravitee-Api-Key`) | | +| EXTERNAL_NATIONAL_ID_SYSTEM_API_KEY | API key value (injected server-side via nginx; keep empty in source control) | | +| EXTERNAL_NATIONAL_ID_REGEX | Regex pattern to validate the external ID format (e.g., CURP) | | +| EXTERNAL_NATIONALID_API_URL | Full upstream URL for nginx proxy_pass | | + +When `ENABLE_EXTERNAL_NATIONAL_ID_SYSTEM` is set to `true`, the following fields are auto-filled and disabled during client creation/editing after a successful lookup: + +- First Name +- Middle Name +- Last Name +- Date of Birth +- Gender + +The user types the External ID value, and if it matches the configured regex, the system calls the external API to retrieve and auto-fill client information. + +**Docker Compose with External National ID:** + +```bash +docker-compose -f docker-compose.yml -f docker-compose.external-nationalid.yml up -d +``` + For more detailed configuration options, refer to the `env.sample` file in the root directory of the project. ### Client Data Masking Example diff --git a/angular.json b/angular.json index 2f4082a47..323a10ada 100644 --- a/angular.json +++ b/angular.json @@ -104,7 +104,8 @@ "serve": { "builder": "@angular/build:dev-server", "options": { - "buildTarget": "mifosx-web-app:build" + "buildTarget": "mifosx-web-app:build", + "proxyConfig": "proxy.conf.js" }, "configurations": { "development": { diff --git a/docker-compose.external-nationalid.yml b/docker-compose.external-nationalid.yml new file mode 100644 index 000000000..a287e864e --- /dev/null +++ b/docker-compose.external-nationalid.yml @@ -0,0 +1,28 @@ +# Docker Compose override for External National ID System integration +# Usage: docker-compose -f docker-compose.yml -f docker-compose.external-nationalid.yml up -d +# +# This file extends the base docker-compose.yml to add: +# - External National ID environment variables +# - Custom nginx configuration with API key injection and rate limiting +# +# The nginx.conf.template is mounted as a volume to configure: +# - Server-side API key injection via proxy_set_header (never exposed to browser) +# - Rate limiting (2 req/s per IP, burst=5) to protect upstream API +# - Path rewriting for /external-nationalid → upstream API + +version: '3.5' + +services: + mifosx-web-app: + volumes: + - ./nginx.conf.template:/etc/nginx/templates/default.conf.template:ro + environment: + # External National ID System + - ENABLE_EXTERNAL_NATIONAL_ID_SYSTEM=true + - EXTERNAL_NATIONAL_ID_SYSTEM_URL=/external-nationalid + # API header/key are injected server-side via nginx proxy_set_header + - EXTERNAL_NATIONAL_ID_SYSTEM_API_HEADER=X-Gravitee-Api-Key + - EXTERNAL_NATIONAL_ID_SYSTEM_API_KEY=${EXTERNAL_NATIONAL_ID_SYSTEM_API_KEY:-} + - EXTERNAL_NATIONAL_ID_REGEX=^[A-Z]{4}[0-9]{6}[HM][A-Z]{5}[A-Z0-9]{2}$ + # Full upstream URL for nginx proxy_pass + - EXTERNAL_NATIONALID_API_URL=${EXTERNAL_NATIONALID_API_URL:-https://apis.mifos.community/1.0/nationalid} diff --git a/docker-compose.yml b/docker-compose.yml index f27604bf0..5f0bb0086 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -25,5 +25,3 @@ services: - MIFOS_SESSION_IDLE_TIMEOUT=300000 - MIFOS_PRELOAD_CLIENTS=true - MIFOS_DEFAULT_CHAR_DELIMITER=, - # Production mode - set to true for minimal hero with only branding - - MIFOS_PRODUCTION_MODE=false diff --git a/env.sample b/env.sample index a130aff08..0d00c6c6b 100644 --- a/env.sample +++ b/env.sample @@ -50,3 +50,17 @@ MIFOS_PRODUCTION_MODE=false # When true: Menus/buttons are shown based on user permissions (production mode) # When false (default): All menus/buttons are shown (backward compatibility) MIFOS_PRODUCTION_MODE_ENABLE_RBAC=false + +# External National ID System integration +# Set to true to enable auto-lookup of client data from an external ID system +ENABLE_EXTERNAL_NATIONAL_ID_SYSTEM=false +# URL of the external National ID API (proxied via /external-nationalid in dev) +EXTERNAL_NATIONAL_ID_SYSTEM_URL=/external-nationalid +# Header name for the external API key (injected server-side via nginx) +EXTERNAL_NATIONAL_ID_SYSTEM_API_HEADER=X-Gravitee-Api-Key +# API key value (keep empty in source control; injected server-side via nginx proxy_set_header) +EXTERNAL_NATIONAL_ID_SYSTEM_API_KEY= +# Regex pattern to validate the external ID format (e.g. CURP) +EXTERNAL_NATIONAL_ID_REGEX=^[A-Z]{4}[0-9]{6}[HM][A-Z]{5}[A-Z0-9]{2}$ +# Full upstream URL for nginx proxy_pass (e.g. https://apis.mifos.community/1.0/nationalid) +EXTERNAL_NATIONALID_API_URL= diff --git a/nginx.conf.template b/nginx.conf.template new file mode 100644 index 000000000..3a5a5c912 --- /dev/null +++ b/nginx.conf.template @@ -0,0 +1,59 @@ +# Rate limiting zone for external National ID API (2 requests/second per IP) +limit_req_zone $binary_remote_addr zone=external_nationalid:10m rate=2r/s; + +server { + listen 80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + # Fineract API proxy + location /fineract-provider/ { + proxy_pass ${FINERACT_API_URL}/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # External National ID API proxy (only if configured) + location /external-nationalid { + # Use Docker DNS resolver for upstream resolution + resolver 127.0.0.11 valid=30s; + + # Rate limit to prevent abuse of the upstream National ID API + limit_req zone=external_nationalid burst=5 nodelay; + + # Read target from env var at runtime (set via envsubst on container start) + set $external_nationalid_target "${EXTERNAL_NATIONALID_API_URL}"; + + # Rewrite path: strip /external-nationalid prefix before proxying + rewrite ^/external-nationalid(.*)$ $1 break; + + proxy_pass $external_nationalid_target; + proxy_set_header Host $proxy_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # Inject API key server-side so it is never exposed to the browser + proxy_set_header ${EXTERNAL_NATIONAL_ID_SYSTEM_API_HEADER} "${EXTERNAL_NATIONAL_ID_SYSTEM_API_KEY}"; + + # Pass through the original headers from the client + proxy_pass_request_headers on; + + # CORS is not needed since requests come from the same origin + } + + # Angular app - serve index.html for all routes + location / { + try_files $uri $uri/ /index.html; + } + + # Cache static assets + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } +} diff --git a/proxy.conf.js b/proxy.conf.js index 9484effba..072f9c8d5 100644 --- a/proxy.conf.js +++ b/proxy.conf.js @@ -43,6 +43,38 @@ const proxyConfig = [ res.end('Proxy error: ' + (err && err.message ? err.message : 'Unknown error')); } } + }, + { + context: ['/external-nationalid'], + target: 'https://apis.mifos.community', + pathRewrite: { '^/external-nationalid': '/1.0/nationalid' }, + changeOrigin: true, + secure: true, + logLevel: 'debug', + onProxyReq: function (proxyReq, req, res) { + // Inject API key server-side (same as nginx proxy_set_header in production) + const apiKey = process.env.EXTERNAL_NATIONAL_ID_SYSTEM_API_KEY || ''; + if (apiKey) { + proxyReq.setHeader('X-Gravitee-Api-Key', apiKey); + } + const rewrittenPath = (req.url || '').replace(/^\/external-nationalid/, '/1.0/nationalid'); + console.log('[Proxy] Proxying:', req.method, req.url, '->', this.target + rewrittenPath); + }, + onError: function (err, req, res) { + console.error( + '[Proxy] Error while proxying request:', + req && req.method, + req && req.url, + '->', + this.target, + '-', + err && err.message + ); + if (res && !res.headersSent) { + res.writeHead(502, { 'Content-Type': 'text/plain' }); + res.end('Proxy error: ' + (err && err.message ? err.message : 'Unknown error')); + } + } } // For local development use `proxy.localhost.conf js` . ]; diff --git a/proxy.localhost.conf.js b/proxy.localhost.conf.js index 28a915b70..5f295e7e3 100644 --- a/proxy.localhost.conf.js +++ b/proxy.localhost.conf.js @@ -40,5 +40,37 @@ module.exports = [ res.end('Proxy error: ' + (err && err.message ? err.message : 'Unknown error')); } } + }, + { + context: ['/external-nationalid'], + target: 'https://apis.mifos.community', + pathRewrite: { '^/external-nationalid': '/1.0/nationalid' }, + changeOrigin: true, + secure: true, + logLevel: 'debug', + onProxyReq: function (proxyReq, req, res) { + // Inject API key server-side (same as nginx proxy_set_header in production) + const apiKey = process.env.EXTERNAL_NATIONAL_ID_SYSTEM_API_KEY || ''; + if (apiKey) { + proxyReq.setHeader('X-Gravitee-Api-Key', apiKey); + } + const rewrittenPath = (req.url || '').replace(/^\/external-nationalid/, '/1.0/nationalid'); + console.log('[Proxy] Proxying:', req.method, req.url, '->', this.target + rewrittenPath); + }, + onError: function (err, req, res) { + console.error( + '[Proxy] Error while proxying request:', + req && req.method, + req && req.url, + '->', + this.target, + '-', + err && err.message + ); + if (res && !res.headersSent) { + res.writeHead(502, { 'Content-Type': 'text/plain' }); + res.end('Proxy error: ' + (err && err.message ? err.message : 'Unknown error')); + } + } } ]; 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 5e00fa397..ba69c7b13 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 @@ -39,9 +39,22 @@ {{ 'labels.inputs.External Id' | translate }} + @if (!externalNationalIdService.isLoading && externalNationalIdService.statusMessageKey) { + + + {{ 'labels.inputs.' + externalNationalIdService.statusMessageKey | translate }} + + + } - @if (createClientForm.contains('fullname')) { + @if (createClientForm.get('fullname')) { {{ 'labels.inputs.' + getDateLabel(createClientForm.value.legalFormId, ['Name', 'Entity Name']) | translate }} @@ -62,13 +75,9 @@ } - @if ( - createClientForm.contains('firstname') || - createClientForm.contains('middlename') || - createClientForm.contains('lastname') - ) { + @if (createClientForm.get('firstname') || createClientForm.get('middlename') || createClientForm.get('lastname')) {
- @if (createClientForm.contains('firstname')) { + @if (createClientForm.get('firstname')) { {{ 'labels.inputs.First Name' | translate }} @@ -87,7 +96,7 @@ } } - @if (createClientForm.contains('middlename')) { + @if (createClientForm.get('middlename')) { {{ 'labels.inputs.Middle Name' | translate }} @@ -100,7 +109,7 @@ } } - @if (createClientForm.contains('lastname')) { + @if (createClientForm.get('lastname')) { {{ 'labels.inputs.Last Name' | translate }} diff --git a/src/app/clients/client-stepper/client-general-step/client-general-step.component.scss b/src/app/clients/client-stepper/client-general-step/client-general-step.component.scss index 45a6a8f47..c70f269b7 100644 --- a/src/app/clients/client-stepper/client-general-step/client-general-step.component.scss +++ b/src/app/clients/client-stepper/client-general-step/client-general-step.component.scss @@ -123,6 +123,23 @@ mat-checkbox { } } +.external-id-status { + font-size: 12px; + font-weight: 500; + + &.loading { + color: #1976d2; + } + + &.success { + color: #388e3c; + } + + &.error { + color: #d32f2f; + } +} + @media (width <= 480px) { form { padding: 8px 0; 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 d4649372f..d607e414c 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 @@ -20,6 +20,7 @@ import { filter, switchMap, takeUntil } from 'rxjs/operators'; 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'; /** Custom Services */ import { SettingsService } from 'app/settings/settings.service'; @@ -37,6 +38,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; selector: 'mifosx-client-general-step', templateUrl: './client-general-step.component.html', styleUrls: ['./client-general-step.component.scss'], + providers: [ExternalNationalIdService], imports: [ ...STANDALONE_SHARED_IMPORTS, MatDivider, @@ -52,6 +54,7 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy { private dateUtils = inject(Dates); private settingsService = inject(SettingsService); private clientService = inject(ClientsService); + externalNationalIdService = inject(ExternalNationalIdService); /** Subject to trigger unsubscription on destroy */ private destroy$ = new Subject(); @@ -104,6 +107,7 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy { this.maxDate = this.settingsService.businessDate; this.setOptions(); this.buildDependencies(); + this.externalNationalIdService.watchExternalId(this.createClientForm, this.genderOptions); } /** diff --git a/src/app/clients/clients.service.ts b/src/app/clients/clients.service.ts index 4d9ff8f80..776f1c4f3 100644 --- a/src/app/clients/clients.service.ts +++ b/src/app/clients/clients.service.ts @@ -8,12 +8,14 @@ /** Angular Imports */ import { Injectable, inject } from '@angular/core'; -import { HttpClient, HttpParams } from '@angular/common/http'; +import { HttpClient, HttpParams, HttpBackend, HttpHeaders } from '@angular/common/http'; /** rxjs Imports */ import { Observable, of, throwError } from 'rxjs'; import { map, catchError } from 'rxjs/operators'; +import { environment } from 'environments/environment'; + /** * Clients service. */ @@ -22,6 +24,10 @@ import { map, catchError } from 'rxjs/operators'; }) export class ClientsService { private http = inject(HttpClient); + private httpBackend = inject(HttpBackend); + + /** Separate HttpClient that bypasses interceptors (for external API calls) */ + private externalHttp = new HttpClient(this.httpBackend); getFilteredClients( orderBy: string, @@ -436,4 +442,34 @@ export class ClientsService { } return this.http.post(`/v2/clients/search`, request); } + + /** + * Lookup external National ID from the configured external system. + * Uses a separate HttpClient (via HttpBackend) to bypass Angular interceptors + * so that Fineract auth headers are not sent to the external API. + * + * In development, requests go through the dev proxy (/external-nationalid). + * In production, requests go through the nginx reverse proxy. + * + * @param externalId The National ID string (e.g. CURP) + */ + lookupExternalNationalId(externalId: string): Observable { + const apiUrl = environment.externalNationalIdSystemUrl; + if (!apiUrl) { + return throwError(() => new Error('External National ID System URL is not configured')); + } + + let headers = new HttpHeaders({ 'Content-Type': 'application/json' }); + const apiHeader = environment.externalNationalIdSystemApiHeader; + const apiKey = environment.externalNationalIdSystemApiKey; + if (apiHeader && apiKey) { + // Validate header name to prevent Angular from throwing on invalid header + if (!/^[a-zA-Z][a-zA-Z0-9-]*$/.test(apiHeader)) { + return throwError(() => new Error(`Invalid API header name: '${apiHeader}'`)); + } + headers = headers.set(apiHeader, apiKey); + } + + return this.externalHttp.post(apiUrl, { externalId }, { headers }); + } } diff --git a/src/app/clients/edit-client/edit-client.component.html b/src/app/clients/edit-client/edit-client.component.html index 2ccf2622f..83426769d 100644 --- a/src/app/clients/edit-client/edit-client.component.html +++ b/src/app/clients/edit-client/edit-client.component.html @@ -58,12 +58,25 @@ [placeholder]="'labels.inputs.External Id' | translate" [title]="'labels.inputs.External Id' | translate" /> + @if (!externalNationalIdService.isLoading && externalNationalIdService.statusMessageKey) { + + + {{ 'labels.inputs.' + externalNationalIdService.statusMessageKey | translate }} + + + }
- @if (editClientForm.contains('fullname')) { + @if (editClientForm.get('fullname')) { {{ 'labels.inputs.' + getDateLabel(legalFormId, ['Name', 'Entity Name']) | translate @@ -84,7 +97,7 @@ } - @if (editClientForm.contains('firstname')) { + @if (editClientForm.get('firstname')) { {{ 'labels.inputs.First Name' | translate }} } - @if (editClientForm.contains('middlename')) { + @if (editClientForm.get('middlename')) { {{ 'labels.inputs.Middle Name' | translate }} } - @if (editClientForm.contains('lastname')) { + @if (editClientForm.get('lastname')) { {{ 'labels.inputs.Last Name' | translate }} + * {{ statusMessageKey | translate }} + * + * + * // In service: + * this.statusMessageKey = EXTERNAL_ID_STATUS_KEYS.SUCCESS; + */ +export const EXTERNAL_ID_STATUS_KEYS = { + /** Successfully verified and retrieved client data from external API */ + SUCCESS: 'External ID verified successfully', + + /** External ID not found in external system (404 response) */ + NOT_FOUND: 'External ID not found', + + /** External ID format is invalid (client-side regex validation failed) */ + INVALID_FORMAT: 'External ID format is invalid', + + /** API call timed out after 10 seconds */ + TIMEOUT: 'External ID lookup timed out', + + /** Generic API error (5xx, network error, etc.) */ + FAILED: 'External ID lookup failed', + + /** Currently fetching data from external API */ + LOADING: 'Looking up External ID...', + + /** No status message (reset state) */ + EMPTY: '' +} as const; + +/** + * Type representing all possible status message keys. + * Useful for type-safe status handling in components. + * + * @example + * let currentStatus: ExternalIdStatusKey = EXTERNAL_ID_STATUS_KEYS.SUCCESS; + */ +export type ExternalIdStatusKey = (typeof EXTERNAL_ID_STATUS_KEYS)[keyof typeof EXTERNAL_ID_STATUS_KEYS]; + +/** + * Mapping from external API gender IDs to gender names. + * The external API uses different IDs (36=Male, 37=Female) than Fineract. + * We map by name to find the corresponding Fineract gender option. + * + * @example + * const genderName = EXTERNAL_GENDER_MAP[36]; // 'Male' + * const fineractOption = genderOptions.find(opt => opt.name === genderName); + */ +export const EXTERNAL_GENDER_MAP: Readonly> = { + 36: 'Male', + 37: 'Female' +}; + +/** + * Fields that are auto-filled and disabled when external ID is verified. + * These are person-specific fields that should not exist for Entity legal forms. + */ +export const PERSON_FIELD_NAMES = [ + 'firstname', + 'middlename', + 'lastname', + 'dateOfBirth', + 'genderId' +] as const; + +/** + * Type representing the person field names. + * Useful for type-safe field access in components. + */ +export type PersonFieldName = (typeof PERSON_FIELD_NAMES)[number]; diff --git a/src/app/clients/services/external-national-id.service.ts b/src/app/clients/services/external-national-id.service.ts new file mode 100644 index 000000000..f205d471c --- /dev/null +++ b/src/app/clients/services/external-national-id.service.ts @@ -0,0 +1,308 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { inject, Injectable, OnDestroy } from '@angular/core'; +import { UntypedFormGroup } from '@angular/forms'; +import { Subject } from 'rxjs'; +import { debounceTime, distinctUntilChanged, filter, switchMap, takeUntil, timeout, catchError } from 'rxjs/operators'; +import { of } from 'rxjs'; +import { environment } from 'environments/environment'; +import { ClientsService } from '../clients.service'; +import { + EXTERNAL_ID_STATUS_KEYS, + EXTERNAL_GENDER_MAP, + PERSON_FIELD_NAMES, + type PersonFieldName, + type ExternalIdStatusKey +} from './external-national-id.constants'; + +/** Response shape from the external National ID API */ +export interface ExternalNationalIdResponse { + firstname?: string; + middlename?: string; + lastname?: string; + dateOfBirth?: string; + dateFormat?: string; + genderId?: number; + developerMessage?: string; +} + +/** Fineract gender option (from client template) */ +export interface GenderOption { + id: number; + name: string; + active?: boolean; + mandatory?: boolean; +} + +/** + * Shared service for External National ID System integration. + * + * Provided at the component level (not `providedIn: 'root'`) so each component + * gets its own instance with independent lifecycle. The service's `ngOnDestroy` + * is called when the host component is destroyed, cleaning up subscriptions. + * + * When enabled, watches the externalId form control: + * - Validates input against configured regex (e.g. CURP) + * - Calls external API to lookup client data + * - Auto-fills firstname, middlename, lastname, dateOfBirth, genderId + * - Disables those fields so users cannot modify API-provided values + */ +@Injectable() +export class ExternalNationalIdService implements OnDestroy { + /** Whether the feature is enabled via env var */ + readonly enabled: boolean; + + /** + * Compiled regex pattern for external ID validation. + * Compiled once in the constructor to avoid repeated `new RegExp()` calls (CWE-1333 mitigation). + * Null if regex is not configured or is invalid. + */ + private readonly pattern: RegExp | null; + + /** Status message key for the template to display */ + statusMessageKey: ExternalIdStatusKey = EXTERNAL_ID_STATUS_KEYS.EMPTY; + + /** Whether a lookup is currently in progress */ + isLoading = false; + + private destroy$ = new Subject(); + + private clientsService = inject(ClientsService); + + constructor() { + this.enabled = environment.enableExternalNationalIdSystem === true; + + const regexStr = environment.externalNationalIdRegex; + if (regexStr) { + try { + this.pattern = new RegExp(regexStr); + } catch { + console.warn('[ExternalNationalId] Invalid regex pattern:', regexStr); + this.pattern = null; + } + } else { + this.pattern = null; + } + } + + /** + * Validates an external ID against the compiled regex pattern. + */ + isValidExternalId(value: string): boolean { + if (!value || !this.pattern) { + return false; + } + return this.pattern.test(value.trim()); + } + + /** + * Start watching the externalId form control for changes. + * When a valid ID is entered, calls the external API and fills form fields. + * + * @param form The reactive form containing externalId and person fields + * @param genderOptions Array of gender options from the Fineract template + * @param skipInitialValue If true, skips lookup for the current form value (used in edit mode + * to avoid re-fetching data for an already-saved external ID) + */ + watchExternalId(form: UntypedFormGroup, genderOptions: GenderOption[], skipInitialValue = false): void { + if (!this.enabled) { + return; + } + + const externalIdCtrl = form.get('externalId'); + if (!externalIdCtrl) { + return; + } + + // In edit mode, if the client already has a valid external ID, lock the fields + // without making an API call + if (skipInitialValue && externalIdCtrl.value && this.isValidExternalId(externalIdCtrl.value)) { + this.disablePersonFields(form); + return; + } + + externalIdCtrl.valueChanges + .pipe( + debounceTime(500), + distinctUntilChanged(), + filter((value: string) => { + if (!value || !this.isValidExternalId(value)) { + // Re-enable fields if ID is cleared or invalid + this.enablePersonFields(form); + if (value && value.length > 3 && !this.isValidExternalId(value)) { + this.statusMessageKey = EXTERNAL_ID_STATUS_KEYS.INVALID_FORMAT; + } else { + this.statusMessageKey = EXTERNAL_ID_STATUS_KEYS.EMPTY; + } + return false; + } + return true; + }), + switchMap((value: string) => { + this.isLoading = true; + this.statusMessageKey = EXTERNAL_ID_STATUS_KEYS.LOADING; + return this.clientsService.lookupExternalNationalId(value).pipe( + timeout(10000), + catchError((error) => { + this.isLoading = false; + if (error.name === 'TimeoutError') { + this.statusMessageKey = EXTERNAL_ID_STATUS_KEYS.TIMEOUT; + } else if (error.status === 404) { + this.statusMessageKey = EXTERNAL_ID_STATUS_KEYS.NOT_FOUND; + } else { + this.statusMessageKey = EXTERNAL_ID_STATUS_KEYS.FAILED; + } + this.enablePersonFields(form); + return of(null); + }) + ); + }), + takeUntil(this.destroy$) + ) + .subscribe((response: ExternalNationalIdResponse | null) => { + this.isLoading = false; + if (response) { + this.fillFormFromResponse(form, response, genderOptions); + } + }); + } + + /** + * Fill form fields from the external API response and disable them. + * Guards each control access to handle the case where the legal form changes + * while a lookup is in-flight (e.g. controls removed by buildDependencies). + */ + private fillFormFromResponse( + form: UntypedFormGroup, + response: ExternalNationalIdResponse, + genderOptions: GenderOption[] + ): void { + const devMessage = response.developerMessage || ''; + + if (devMessage === 'client.externalid.notfound') { + this.statusMessageKey = EXTERNAL_ID_STATUS_KEYS.NOT_FOUND; + this.enablePersonFields(form); + return; + } + + if (devMessage === 'client.externalid.invalid') { + this.statusMessageKey = EXTERNAL_ID_STATUS_KEYS.INVALID_FORMAT; + this.enablePersonFields(form); + return; + } + + // Valid response — fill and disable fields + this.statusMessageKey = EXTERNAL_ID_STATUS_KEYS.SUCCESS; + + const firstnameCtrl = form.get('firstname'); + const middlenameCtrl = form.get('middlename'); + const lastnameCtrl = form.get('lastname'); + const dobCtrl = form.get('dateOfBirth'); + const genderCtrl = form.get('genderId'); + + // Guard: if person-specific controls were removed (legal form changed to Entity + // while lookup was in-flight), skip those fields gracefully + if (firstnameCtrl && response.firstname) { + firstnameCtrl.setValue(response.firstname); + firstnameCtrl.disable(); + } + + if (middlenameCtrl && response.middlename) { + middlenameCtrl.setValue(response.middlename); + middlenameCtrl.disable(); + } + + if (lastnameCtrl && response.lastname) { + lastnameCtrl.setValue(response.lastname); + lastnameCtrl.disable(); + } + + if (dobCtrl && response.dateOfBirth) { + const parsedDate = this.parseDate(response.dateOfBirth, response.dateFormat); + if (parsedDate) { + dobCtrl.setValue(parsedDate); + dobCtrl.disable(); + } + } + + if (genderCtrl && response.genderId != null) { + const genderName = EXTERNAL_GENDER_MAP[response.genderId]; + const matchedGender = genderName + ? genderOptions?.find((g: GenderOption) => g.name?.toLowerCase() === genderName.toLowerCase()) + : null; + if (matchedGender) { + genderCtrl.setValue(matchedGender.id); + genderCtrl.disable(); + } + } + } + + /** + * Parse a date string using the provided format. + * Validates the parsed result against the expected format to catch mismatches. + * + * @param dateStr The date string from the API (e.g. "15/03/1990") + * @param dateFormat The format hint from the API (e.g. "dd/MM/yyyy") + * @returns A valid Date object or null if parsing fails + */ + parseDate(dateStr: string, dateFormat?: string): Date | null { + if (!dateStr) { + return null; + } + + const format = dateFormat || 'dd/MM/yyyy'; + const parts = dateStr.split('/'); + + if (format === 'dd/MM/yyyy' && parts.length === 3) { + const day = parseInt(parts[0], 10); + const month = parseInt(parts[1], 10) - 1; + const year = parseInt(parts[2], 10); + + // Validate ranges before constructing + if (day < 1 || day > 31 || month < 0 || month > 11 || year < 1900 || year > 2100) { + return null; + } + + const date = new Date(year, month, day); + + // Verify the date components match (catches invalid dates like Feb 30) + if (isNaN(date.getTime()) || date.getDate() !== day || date.getMonth() !== month || date.getFullYear() !== year) { + return null; + } + return date; + } + + // Fallback: try native parsing + const fallback = new Date(dateStr); + return isNaN(fallback.getTime()) ? null : fallback; + } + + /** + * Re-enable person fields so they can be edited manually. + */ + enablePersonFields(form: UntypedFormGroup): void { + for (const field of PERSON_FIELD_NAMES) { + form.get(field)?.enable(); + } + } + + /** + * Disable person fields (used in edit mode to lock fields for existing external IDs). + */ + private disablePersonFields(form: UntypedFormGroup): void { + for (const field of PERSON_FIELD_NAMES) { + form.get(field)?.disable(); + } + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } +} diff --git a/src/assets/env.js b/src/assets/env.js index 93c20a168..469251823 100644 --- a/src/assets/env.js +++ b/src/assets/env.js @@ -91,6 +91,16 @@ // Set to true to enable RBAC, false (default) for backward compatibility window['env']['productionModeEnableRBAC'] = false; + // External National ID System + // Set to 'true' to enable External National ID lookup during client creation/editing + // When enabled, set EXTERNAL_NATIONAL_ID_SYSTEM_URL, API_HEADER, API_KEY, and REGEX + // In production, API key is injected server-side via nginx proxy_set_header (never set here) + window['env']['enableExternalNationalIdSystem'] = 'false'; + window['env']['externalNationalIdSystemUrl'] = ''; + window['env']['externalNationalIdSystemApiHeader'] = ''; + window['env']['externalNationalIdSystemApiKey'] = ''; + window['env']['externalNationalIdRegex'] = ''; + // OIDC Plugin Environment variables window['env']['oidcServerEnabled'] = false; window['env']['oidcBaseUrl'] = ''; diff --git a/src/assets/env.template.js b/src/assets/env.template.js index 8f748fb95..da0d11452 100644 --- a/src/assets/env.template.js +++ b/src/assets/env.template.js @@ -103,6 +103,14 @@ // Set to 'true' to enable RBAC, 'false' (default) for backward compatibility window['env']['productionModeEnableRBAC'] = '$MIFOS_PRODUCTION_MODE_ENABLE_RBAC'; + // External National ID System Integration + // API key is injected server-side via nginx proxy_set_header — not exposed to browser + window['env']['enableExternalNationalIdSystem'] = '$ENABLE_EXTERNAL_NATIONAL_ID_SYSTEM'; + window['env']['externalNationalIdSystemUrl'] = '$EXTERNAL_NATIONAL_ID_SYSTEM_URL'; + window['env']['externalNationalIdSystemApiHeader'] = '$EXTERNAL_NATIONAL_ID_SYSTEM_API_HEADER'; + window['env']['externalNationalIdSystemApiKey'] = '$EXTERNAL_NATIONAL_ID_SYSTEM_API_KEY'; + window['env']['externalNationalIdRegex'] = '$EXTERNAL_NATIONAL_ID_REGEX'; + // OIDC Plugin Environment variables window['env']['oidcServerEnabled'] = '$FINERACT_PLUGIN_OIDC_ENABLED'; window['env']['oidcBaseUrl'] = '$FINERACT_PLUGIN_OIDC_BASE_URL'; diff --git a/src/assets/translations/en-US.json b/src/assets/translations/en-US.json index 2a2b90619..ae2981971 100644 --- a/src/assets/translations/en-US.json +++ b/src/assets/translations/en-US.json @@ -1760,6 +1760,12 @@ "External ID": "External ID", "External Id": "External Id", "External id": "External id", + "External ID not found": "External ID not found", + "External ID lookup failed": "External ID lookup failed", + "External ID format is invalid": "External ID format is invalid", + "External ID verified successfully": "External ID verified successfully", + "Looking up External ID...": "Looking up External ID...", + "External ID lookup timed out": "External ID lookup timed out", "event with entity name of": "event with entity name of", "FAMILY MEMBERS": "FAMILY MEMBERS", "FCM End Point": "FCM End Point", diff --git a/src/environments/environment.prod.ts b/src/environments/environment.prod.ts index b10101ca9..51c0849b4 100644 --- a/src/environments/environment.prod.ts +++ b/src/environments/environment.prod.ts @@ -85,6 +85,13 @@ export const environment = { minPasswordLength: loadedEnv['minPasswordLength'] || 12, + /** External National ID System integration */ + enableExternalNationalIdSystem: loadedEnv['enableExternalNationalIdSystem'] === 'true' || false, + externalNationalIdSystemUrl: loadedEnv['externalNationalIdSystemUrl'] || '', + externalNationalIdSystemApiHeader: loadedEnv['externalNationalIdSystemApiHeader'] || '', + externalNationalIdSystemApiKey: loadedEnv['externalNationalIdSystemApiKey'] || '', + externalNationalIdRegex: loadedEnv['externalNationalIdRegex'] || '', + /** * 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 e364c711d..c25c2bc63 100644 --- a/src/environments/environment.ts +++ b/src/environments/environment.ts @@ -85,6 +85,17 @@ export const environment = { mifosInterbankTransfersApiVersion: window.env?.mifosInterbankTransfersApiVersion || '/v1.0', mifosInterbankTransfersEnabled: window.env?.mifosInterbankTransfersEnabled ?? true, + /** + * External National ID System Integration + * When enabled, client creation/editing will lookup external National ID + * and auto-fill client details (name, DOB, gender) from the external system. + */ + enableExternalNationalIdSystem: loadedEnv.enableExternalNationalIdSystem === 'true' || false, + externalNationalIdSystemUrl: loadedEnv.externalNationalIdSystemUrl || '', + externalNationalIdSystemApiHeader: loadedEnv.externalNationalIdSystemApiHeader || '', + externalNationalIdSystemApiKey: loadedEnv.externalNationalIdSystemApiKey || '', + externalNationalIdRegex: loadedEnv.externalNationalIdRegex || '', + minPasswordLength: loadedEnv.minPasswordLength || 12, /** diff --git a/src/typings.d.ts b/src/typings.d.ts index cb2e8bc89..9b57417dd 100644 --- a/src/typings.d.ts +++ b/src/typings.d.ts @@ -41,6 +41,11 @@ interface Window { mifosInterbankTransfersApiVersion?: string; mifosInterbankTransfersEnabled?: boolean; minPasswordLength?: number; + enableExternalNationalIdSystem?: string; + externalNationalIdSystemUrl?: string; + externalNationalIdSystemApiHeader?: string; + externalNationalIdSystemApiKey?: string; + externalNationalIdRegex?: string; }; }