WEB-600 feat:External National ID System integration for client creation/editing (#3145)

improve
This commit is contained in:
Shubham Kumar
2026-02-13 13:40:19 -06:00
committed by GitHub
parent ec6ec2edc1
commit 1ea9f88bb2
23 changed files with 752 additions and 21 deletions
+29
View File
@@ -313,6 +313,35 @@ Available languages:
| FINERACT_PLUGIN_OIDC_API_URL | Set the Client API URL | | | FINERACT_PLUGIN_OIDC_API_URL | Set the Client API URL | |
| FINERACT_PLUGIN_OIDC_FRONTEND_URL | Set the Front End URL callback | | | 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. For more detailed configuration options, refer to the `env.sample` file in the root directory of the project.
### Client Data Masking Example ### Client Data Masking Example
+2 -1
View File
@@ -104,7 +104,8 @@
"serve": { "serve": {
"builder": "@angular/build:dev-server", "builder": "@angular/build:dev-server",
"options": { "options": {
"buildTarget": "mifosx-web-app:build" "buildTarget": "mifosx-web-app:build",
"proxyConfig": "proxy.conf.js"
}, },
"configurations": { "configurations": {
"development": { "development": {
+28
View File
@@ -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}
-2
View File
@@ -25,5 +25,3 @@ services:
- MIFOS_SESSION_IDLE_TIMEOUT=300000 - MIFOS_SESSION_IDLE_TIMEOUT=300000
- MIFOS_PRELOAD_CLIENTS=true - MIFOS_PRELOAD_CLIENTS=true
- MIFOS_DEFAULT_CHAR_DELIMITER=, - MIFOS_DEFAULT_CHAR_DELIMITER=,
# Production mode - set to true for minimal hero with only branding
- MIFOS_PRODUCTION_MODE=false
+14
View File
@@ -50,3 +50,17 @@ MIFOS_PRODUCTION_MODE=false
# When true: Menus/buttons are shown based on user permissions (production mode) # When true: Menus/buttons are shown based on user permissions (production mode)
# When false (default): All menus/buttons are shown (backward compatibility) # When false (default): All menus/buttons are shown (backward compatibility)
MIFOS_PRODUCTION_MODE_ENABLE_RBAC=false 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=
+59
View File
@@ -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";
}
}
+32
View File
@@ -43,6 +43,38 @@ const proxyConfig = [
res.end('Proxy error: ' + (err && err.message ? err.message : 'Unknown error')); 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` . // For local development use `proxy.localhost.conf js` .
]; ];
+32
View File
@@ -40,5 +40,37 @@ module.exports = [
res.end('Proxy error: ' + (err && err.message ? err.message : 'Unknown error')); 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'));
}
}
} }
]; ];
@@ -39,9 +39,22 @@
<mat-form-field class="flex-fill flex-23"> <mat-form-field class="flex-fill flex-23">
<mat-label>{{ 'labels.inputs.External Id' | translate }}</mat-label> <mat-label>{{ 'labels.inputs.External Id' | translate }}</mat-label>
<input matInput formControlName="externalId" /> <input matInput formControlName="externalId" />
@if (!externalNationalIdService.isLoading && externalNationalIdService.statusMessageKey) {
<mat-hint>
<span
class="external-id-status"
[ngClass]="{
success: externalNationalIdService.statusMessageKey === 'External ID verified successfully',
error: externalNationalIdService.statusMessageKey !== 'External ID verified successfully'
}"
>
{{ 'labels.inputs.' + externalNationalIdService.statusMessageKey | translate }}
</span>
</mat-hint>
}
</mat-form-field> </mat-form-field>
@if (createClientForm.contains('fullname')) { @if (createClientForm.get('fullname')) {
<mat-form-field class="flex-48"> <mat-form-field class="flex-48">
<mat-label> <mat-label>
{{ 'labels.inputs.' + getDateLabel(createClientForm.value.legalFormId, ['Name', 'Entity Name']) | translate }} {{ 'labels.inputs.' + getDateLabel(createClientForm.value.legalFormId, ['Name', 'Entity Name']) | translate }}
@@ -62,13 +75,9 @@
</mat-form-field> </mat-form-field>
} }
@if ( @if (createClientForm.get('firstname') || createClientForm.get('middlename') || createClientForm.get('lastname')) {
createClientForm.contains('firstname') ||
createClientForm.contains('middlename') ||
createClientForm.contains('lastname')
) {
<div class="name-fields-row"> <div class="name-fields-row">
@if (createClientForm.contains('firstname')) { @if (createClientForm.get('firstname')) {
<mat-form-field class="name-field first-name"> <mat-form-field class="name-field first-name">
<mat-label>{{ 'labels.inputs.First Name' | translate }}</mat-label> <mat-label>{{ 'labels.inputs.First Name' | translate }}</mat-label>
<input matInput required formControlName="firstname" /> <input matInput required formControlName="firstname" />
@@ -87,7 +96,7 @@
} }
</mat-form-field> </mat-form-field>
} }
@if (createClientForm.contains('middlename')) { @if (createClientForm.get('middlename')) {
<mat-form-field class="name-field middle-name"> <mat-form-field class="name-field middle-name">
<mat-label>{{ 'labels.inputs.Middle Name' | translate }}</mat-label> <mat-label>{{ 'labels.inputs.Middle Name' | translate }}</mat-label>
<input matInput formControlName="middlename" /> <input matInput formControlName="middlename" />
@@ -100,7 +109,7 @@
} }
</mat-form-field> </mat-form-field>
} }
@if (createClientForm.contains('lastname')) { @if (createClientForm.get('lastname')) {
<mat-form-field class="name-field last-name"> <mat-form-field class="name-field last-name">
<mat-label>{{ 'labels.inputs.Last Name' | translate }}</mat-label> <mat-label>{{ 'labels.inputs.Last Name' | translate }}</mat-label>
<input matInput required formControlName="lastname" /> <input matInput required formControlName="lastname" />
@@ -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) { @media (width <= 480px) {
form { form {
padding: 8px 0; padding: 8px 0;
@@ -20,6 +20,7 @@ import { filter, switchMap, takeUntil } from 'rxjs/operators';
import { ClientsService } from 'app/clients/clients.service'; import { ClientsService } from 'app/clients/clients.service';
import { Dates } from 'app/core/utils/dates'; import { Dates } from 'app/core/utils/dates';
import { LegalFormId } from 'app/clients/models/legal-form.enum'; import { LegalFormId } from 'app/clients/models/legal-form.enum';
import { ExternalNationalIdService } from 'app/clients/services/external-national-id.service';
/** Custom Services */ /** Custom Services */
import { SettingsService } from 'app/settings/settings.service'; 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', selector: 'mifosx-client-general-step',
templateUrl: './client-general-step.component.html', templateUrl: './client-general-step.component.html',
styleUrls: ['./client-general-step.component.scss'], styleUrls: ['./client-general-step.component.scss'],
providers: [ExternalNationalIdService],
imports: [ imports: [
...STANDALONE_SHARED_IMPORTS, ...STANDALONE_SHARED_IMPORTS,
MatDivider, MatDivider,
@@ -52,6 +54,7 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy {
private dateUtils = inject(Dates); private dateUtils = inject(Dates);
private settingsService = inject(SettingsService); private settingsService = inject(SettingsService);
private clientService = inject(ClientsService); private clientService = inject(ClientsService);
externalNationalIdService = inject(ExternalNationalIdService);
/** Subject to trigger unsubscription on destroy */ /** Subject to trigger unsubscription on destroy */
private destroy$ = new Subject<void>(); private destroy$ = new Subject<void>();
@@ -104,6 +107,7 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy {
this.maxDate = this.settingsService.businessDate; this.maxDate = this.settingsService.businessDate;
this.setOptions(); this.setOptions();
this.buildDependencies(); this.buildDependencies();
this.externalNationalIdService.watchExternalId(this.createClientForm, this.genderOptions);
} }
/** /**
+37 -1
View File
@@ -8,12 +8,14 @@
/** Angular Imports */ /** Angular Imports */
import { Injectable, inject } from '@angular/core'; import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http'; import { HttpClient, HttpParams, HttpBackend, HttpHeaders } from '@angular/common/http';
/** rxjs Imports */ /** rxjs Imports */
import { Observable, of, throwError } from 'rxjs'; import { Observable, of, throwError } from 'rxjs';
import { map, catchError } from 'rxjs/operators'; import { map, catchError } from 'rxjs/operators';
import { environment } from 'environments/environment';
/** /**
* Clients service. * Clients service.
*/ */
@@ -22,6 +24,10 @@ import { map, catchError } from 'rxjs/operators';
}) })
export class ClientsService { export class ClientsService {
private http = inject(HttpClient); private http = inject(HttpClient);
private httpBackend = inject(HttpBackend);
/** Separate HttpClient that bypasses interceptors (for external API calls) */
private externalHttp = new HttpClient(this.httpBackend);
getFilteredClients( getFilteredClients(
orderBy: string, orderBy: string,
@@ -436,4 +442,34 @@ export class ClientsService {
} }
return this.http.post(`/v2/clients/search`, request); 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<any> {
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 });
}
} }
@@ -58,12 +58,25 @@
[placeholder]="'labels.inputs.External Id' | translate" [placeholder]="'labels.inputs.External Id' | translate"
[title]="'labels.inputs.External Id' | translate" [title]="'labels.inputs.External Id' | translate"
/> />
@if (!externalNationalIdService.isLoading && externalNationalIdService.statusMessageKey) {
<mat-hint>
<span
class="external-id-status"
[ngClass]="{
success: externalNationalIdService.statusMessageKey === 'External ID verified successfully',
error: externalNationalIdService.statusMessageKey !== 'External ID verified successfully'
}"
>
{{ 'labels.inputs.' + externalNationalIdService.statusMessageKey | translate }}
</span>
</mat-hint>
}
</mat-form-field> </mat-form-field>
</div> </div>
<!-- Name fields section --> <!-- Name fields section -->
<div class="responsive-row"> <div class="responsive-row">
@if (editClientForm.contains('fullname')) { @if (editClientForm.get('fullname')) {
<mat-form-field class="flex-48"> <mat-form-field class="flex-48">
<mat-label>{{ <mat-label>{{
'labels.inputs.' + getDateLabel(legalFormId, ['Name', 'Entity Name']) | translate 'labels.inputs.' + getDateLabel(legalFormId, ['Name', 'Entity Name']) | translate
@@ -84,7 +97,7 @@
</mat-form-field> </mat-form-field>
} }
@if (editClientForm.contains('firstname')) { @if (editClientForm.get('firstname')) {
<mat-form-field class="flex-32"> <mat-form-field class="flex-32">
<mat-label>{{ 'labels.inputs.First Name' | translate }}</mat-label> <mat-label>{{ 'labels.inputs.First Name' | translate }}</mat-label>
<input <input
@@ -103,7 +116,7 @@
</mat-form-field> </mat-form-field>
} }
@if (editClientForm.contains('middlename')) { @if (editClientForm.get('middlename')) {
<mat-form-field class="flex-32"> <mat-form-field class="flex-32">
<mat-label>{{ 'labels.inputs.Middle Name' | translate }}</mat-label> <mat-label>{{ 'labels.inputs.Middle Name' | translate }}</mat-label>
<input <input
@@ -115,7 +128,7 @@
</mat-form-field> </mat-form-field>
} }
@if (editClientForm.contains('lastname')) { @if (editClientForm.get('lastname')) {
<mat-form-field class="flex-32"> <mat-form-field class="flex-32">
<mat-label>{{ 'labels.inputs.Last Name' | translate }}</mat-label> <mat-label>{{ 'labels.inputs.Last Name' | translate }}</mat-label>
<input <input
@@ -93,10 +93,6 @@
.mat-form-field-outline { .mat-form-field-outline {
color: rgb(0 0 0 / 12%); color: rgb(0 0 0 / 12%);
} }
.mat-form-field-outline-thick {
color: #3f51b5;
}
} }
:host ::ng-deep .mat-form-field.mat-form-field-invalid { :host ::ng-deep .mat-form-field.mat-form-field-invalid {
@@ -229,6 +225,23 @@
color: rgb(0 0 0 / 54%); color: rgb(0 0 0 / 54%);
} }
.external-id-status {
font-size: 12px;
font-weight: 500;
&.loading {
color: #1976d2;
}
&.success {
color: #388e3c;
}
&.error {
color: #d32f2f;
}
}
// Select dropdown styling // Select dropdown styling
:host ::ng-deep .mat-select-panel { :host ::ng-deep .mat-select-panel {
max-height: 256px; max-height: 256px;
@@ -22,6 +22,7 @@ import {
import { ClientsService } from '../clients.service'; import { ClientsService } from '../clients.service';
import { SettingsService } from 'app/settings/settings.service'; import { SettingsService } from 'app/settings/settings.service';
import { Dates } from 'app/core/utils/dates'; import { Dates } from 'app/core/utils/dates';
import { ExternalNationalIdService } from 'app/clients/services/external-national-id.service';
import { MatDivider } from '@angular/material/divider'; import { MatDivider } from '@angular/material/divider';
import { CdkTextareaAutosize } from '@angular/cdk/text-field'; import { CdkTextareaAutosize } from '@angular/cdk/text-field';
import { MatCheckbox } from '@angular/material/checkbox'; import { MatCheckbox } from '@angular/material/checkbox';
@@ -34,6 +35,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
selector: 'mifosx-edit-client', selector: 'mifosx-edit-client',
templateUrl: './edit-client.component.html', templateUrl: './edit-client.component.html',
styleUrls: ['./edit-client.component.scss'], styleUrls: ['./edit-client.component.scss'],
providers: [ExternalNationalIdService],
imports: [ imports: [
...STANDALONE_SHARED_IMPORTS, ...STANDALONE_SHARED_IMPORTS,
MatDivider, MatDivider,
@@ -48,6 +50,7 @@ export class EditClientComponent implements OnInit {
private clientsService = inject(ClientsService); private clientsService = inject(ClientsService);
private dateUtils = inject(Dates); private dateUtils = inject(Dates);
private settingsService = inject(SettingsService); private settingsService = inject(SettingsService);
externalNationalIdService = inject(ExternalNationalIdService);
/** Minimum date allowed. */ /** Minimum date allowed. */
minDate = new Date(2000, 0, 1); minDate = new Date(2000, 0, 1);
@@ -126,6 +129,8 @@ export class EditClientComponent implements OnInit {
if (this.clientDataAndTemplate.legalForm) { if (this.clientDataAndTemplate.legalForm) {
this.legalFormId = this.clientDataAndTemplate.legalForm.id; this.legalFormId = this.clientDataAndTemplate.legalForm.id;
} }
// skipInitialValue=true: avoid re-fetching data for an already-saved external ID
this.externalNationalIdService.watchExternalId(this.editClientForm, this.genderOptions, true);
} }
/** /**
@@ -0,0 +1,86 @@
/**
* 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/.
*/
/**
* i18n translation keys for External National ID System status messages.
*
* These keys correspond to translation entries in assets/translations/*.json files.
* Using constants eliminates string duplication and enables type-safe refactoring.
*
* @example
* // In component template:
* <span [ngClass]="{ 'text-success': statusType === 'success' }">
* {{ statusMessageKey | translate }}
* </span>
*
* // 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<Record<number, string>> = {
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];
@@ -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<void>();
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();
}
}
+10
View File
@@ -91,6 +91,16 @@
// Set to true to enable RBAC, false (default) for backward compatibility // Set to true to enable RBAC, false (default) for backward compatibility
window['env']['productionModeEnableRBAC'] = false; 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 // OIDC Plugin Environment variables
window['env']['oidcServerEnabled'] = false; window['env']['oidcServerEnabled'] = false;
window['env']['oidcBaseUrl'] = ''; window['env']['oidcBaseUrl'] = '';
+8
View File
@@ -103,6 +103,14 @@
// Set to 'true' to enable RBAC, 'false' (default) for backward compatibility // Set to 'true' to enable RBAC, 'false' (default) for backward compatibility
window['env']['productionModeEnableRBAC'] = '$MIFOS_PRODUCTION_MODE_ENABLE_RBAC'; 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 // OIDC Plugin Environment variables
window['env']['oidcServerEnabled'] = '$FINERACT_PLUGIN_OIDC_ENABLED'; window['env']['oidcServerEnabled'] = '$FINERACT_PLUGIN_OIDC_ENABLED';
window['env']['oidcBaseUrl'] = '$FINERACT_PLUGIN_OIDC_BASE_URL'; window['env']['oidcBaseUrl'] = '$FINERACT_PLUGIN_OIDC_BASE_URL';
+6
View File
@@ -1760,6 +1760,12 @@
"External ID": "External ID", "External ID": "External ID",
"External Id": "External Id", "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", "event with entity name of": "event with entity name of",
"FAMILY MEMBERS": "FAMILY MEMBERS", "FAMILY MEMBERS": "FAMILY MEMBERS",
"FCM End Point": "FCM End Point", "FCM End Point": "FCM End Point",
+7
View File
@@ -85,6 +85,13 @@ export const environment = {
minPasswordLength: loadedEnv['minPasswordLength'] || 12, 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 *) * Hide client data information (mask client names with *)
* Set via MIFOS_COMPLIANCE_HIDE_CLIENT_DATA env var * Set via MIFOS_COMPLIANCE_HIDE_CLIENT_DATA env var
+11
View File
@@ -85,6 +85,17 @@ export const environment = {
mifosInterbankTransfersApiVersion: window.env?.mifosInterbankTransfersApiVersion || '/v1.0', mifosInterbankTransfersApiVersion: window.env?.mifosInterbankTransfersApiVersion || '/v1.0',
mifosInterbankTransfersEnabled: window.env?.mifosInterbankTransfersEnabled ?? true, 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, minPasswordLength: loadedEnv.minPasswordLength || 12,
/** /**
+5
View File
@@ -41,6 +41,11 @@ interface Window {
mifosInterbankTransfersApiVersion?: string; mifosInterbankTransfersApiVersion?: string;
mifosInterbankTransfersEnabled?: boolean; mifosInterbankTransfersEnabled?: boolean;
minPasswordLength?: number; minPasswordLength?: number;
enableExternalNationalIdSystem?: string;
externalNationalIdSystemUrl?: string;
externalNationalIdSystemApiHeader?: string;
externalNationalIdSystemApiKey?: string;
externalNationalIdRegex?: string;
}; };
} }