WEB-600 feat:External National ID System integration for client creation/editing (#3145)
improve
This commit is contained in:
@@ -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
@@ -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": {
|
||||||
|
|||||||
@@ -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}
|
||||||
@@ -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
@@ -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=
|
||||||
|
|||||||
@@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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` .
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|||||||
+18
-9
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'] = '';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Vendored
+5
@@ -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;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user