Skip to content

Commit d8e3fdb

Browse files
WEB-600 feat:External National ID System integration for client creation/editing
1 parent f1d1ebf commit d8e3fdb

22 files changed

+711
-21
lines changed

Dockerfile

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,10 @@ RUN sh -c "ng build --output-path=/dist $BUILD_ENVIRONMENT_OPTIONS"
4242
FROM $NGINX_IMAGE
4343

4444
COPY --from=builder /dist/browser /usr/share/nginx/html
45+
COPY nginx.conf.template /etc/nginx/templates/default.conf.template
4546

4647
EXPOSE 80
4748

48-
# When the container starts, replace the env.js with values from environment variables
49-
CMD ["/bin/sh", "-c", "envsubst < /usr/share/nginx/html/assets/env.template.js > /usr/share/nginx/html/assets/env.js && exec nginx -g 'daemon off;'"]
49+
# When the container starts, replace env.js with values from environment variables
50+
# nginx:alpine image auto-processes /etc/nginx/templates/*.template via envsubst
51+
CMD ["/bin/sh", "-c", "envsubst < /usr/share/nginx/html/assets/env.template.js > /usr/share/nginx/html/assets/env.js && envsubst '${FINERACT_API_URL} ${EXTERNAL_NATIONAL_ID_SYSTEM_URL}' < /etc/nginx/templates/default.conf.template > /etc/nginx/conf.d/default.conf && exec nginx -g 'daemon off;'"]

angular.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,8 @@
104104
"serve": {
105105
"builder": "@angular/build:dev-server",
106106
"options": {
107-
"buildTarget": "mifosx-web-app:build"
107+
"buildTarget": "mifosx-web-app:build",
108+
"proxyConfig": "proxy.conf.js"
108109
},
109110
"configurations": {
110111
"development": {

docker-compose.yml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,3 +27,12 @@ services:
2727
- MIFOS_DEFAULT_CHAR_DELIMITER=,
2828
# Production mode - set to true for minimal hero with only branding
2929
- MIFOS_PRODUCTION_MODE=false
30+
# External National ID System
31+
- ENABLE_EXTERNAL_NATIONAL_ID_SYSTEM=false
32+
- EXTERNAL_NATIONAL_ID_SYSTEM_URL=
33+
# API header/key are injected server-side via nginx proxy_set_header
34+
- EXTERNAL_NATIONAL_ID_SYSTEM_API_HEADER=X-Gravitee-Api-Key
35+
- EXTERNAL_NATIONAL_ID_SYSTEM_API_KEY=
36+
- EXTERNAL_NATIONAL_ID_REGEX=^[A-Z]{4}[0-9]{6}[HM][A-Z]{5}[A-Z0-9]{2}$
37+
# Full upstream URL for nginx proxy_pass (e.g. https://apis.mifos.community/1.0/nationalid)
38+
- EXTERNAL_NATIONALID_API_URL=

env.sample

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,3 +50,17 @@ MIFOS_PRODUCTION_MODE=false
5050
# When true: Menus/buttons are shown based on user permissions (production mode)
5151
# When false (default): All menus/buttons are shown (backward compatibility)
5252
MIFOS_PRODUCTION_MODE_ENABLE_RBAC=false
53+
54+
# External National ID System integration
55+
# Set to true to enable auto-lookup of client data from an external ID system
56+
ENABLE_EXTERNAL_NATIONAL_ID_SYSTEM=false
57+
# URL of the external National ID API (proxied via /external-nationalid in dev)
58+
EXTERNAL_NATIONAL_ID_SYSTEM_URL=/external-nationalid
59+
# Header name for the external API key (injected server-side via nginx)
60+
EXTERNAL_NATIONAL_ID_SYSTEM_API_HEADER=X-Gravitee-Api-Key
61+
# API key value (keep empty in source control; injected server-side via nginx proxy_set_header)
62+
EXTERNAL_NATIONAL_ID_SYSTEM_API_KEY=
63+
# Regex pattern to validate the external ID format (e.g. CURP)
64+
EXTERNAL_NATIONAL_ID_REGEX=^[A-Z]{4}[0-9]{6}[HM][A-Z]{5}[A-Z0-9]{2}$
65+
# Full upstream URL for nginx proxy_pass (e.g. https://apis.mifos.community/1.0/nationalid)
66+
EXTERNAL_NATIONALID_API_URL=

nginx.conf.template

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
# Rate limiting zone for external National ID API (2 requests/second per IP)
2+
limit_req_zone $binary_remote_addr zone=external_nationalid:10m rate=2r/s;
3+
4+
server {
5+
listen 80;
6+
server_name _;
7+
8+
root /usr/share/nginx/html;
9+
index index.html;
10+
11+
# Fineract API proxy
12+
location /fineract-provider/ {
13+
proxy_pass ${FINERACT_API_URL}/;
14+
proxy_set_header Host $host;
15+
proxy_set_header X-Real-IP $remote_addr;
16+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
17+
proxy_set_header X-Forwarded-Proto $scheme;
18+
}
19+
20+
# External National ID API proxy (only if configured)
21+
location /external-nationalid {
22+
# Use Docker DNS resolver for upstream resolution
23+
resolver 127.0.0.11 valid=30s;
24+
25+
# Rate limit to prevent abuse of the upstream National ID API
26+
limit_req zone=external_nationalid burst=5 nodelay;
27+
28+
# Read target from env var at runtime (set via envsubst on container start)
29+
set $external_nationalid_target "${EXTERNAL_NATIONALID_API_URL}";
30+
31+
# Rewrite path: strip /external-nationalid prefix before proxying
32+
rewrite ^/external-nationalid(.*)$ $1 break;
33+
34+
proxy_pass $external_nationalid_target;
35+
proxy_set_header Host $proxy_host;
36+
proxy_set_header X-Real-IP $remote_addr;
37+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
38+
proxy_set_header X-Forwarded-Proto $scheme;
39+
40+
# Inject API key server-side so it is never exposed to the browser
41+
proxy_set_header ${EXTERNAL_NATIONAL_ID_SYSTEM_API_HEADER} "${EXTERNAL_NATIONAL_ID_SYSTEM_API_KEY}";
42+
43+
# Pass through the original headers from the client
44+
proxy_pass_request_headers on;
45+
46+
# CORS is not needed since requests come from the same origin
47+
}
48+
49+
# Angular app - serve index.html for all routes
50+
location / {
51+
try_files $uri $uri/ /index.html;
52+
}
53+
54+
# Cache static assets
55+
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
56+
expires 1y;
57+
add_header Cache-Control "public, immutable";
58+
}
59+
}

proxy.conf.js

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,38 @@ const proxyConfig = [
4343
res.end('Proxy error: ' + (err && err.message ? err.message : 'Unknown error'));
4444
}
4545
}
46+
},
47+
{
48+
context: ['/external-nationalid'],
49+
target: 'https://apis.mifos.community',
50+
pathRewrite: { '^/external-nationalid': '/1.0/nationalid' },
51+
changeOrigin: true,
52+
secure: true,
53+
logLevel: 'debug',
54+
onProxyReq: function (proxyReq, req, res) {
55+
// Inject API key server-side (same as nginx proxy_set_header in production)
56+
const apiKey = process.env.EXTERNAL_NATIONAL_ID_SYSTEM_API_KEY || '';
57+
if (apiKey) {
58+
proxyReq.setHeader('X-Gravitee-Api-Key', apiKey);
59+
}
60+
const rewrittenPath = (req.url || '').replace(/^\/external-nationalid/, '/1.0/nationalid');
61+
console.log('[Proxy] Proxying:', req.method, req.url, '->', this.target + rewrittenPath);
62+
},
63+
onError: function (err, req, res) {
64+
console.error(
65+
'[Proxy] Error while proxying request:',
66+
req && req.method,
67+
req && req.url,
68+
'->',
69+
this.target,
70+
'-',
71+
err && err.message
72+
);
73+
if (res && !res.headersSent) {
74+
res.writeHead(502, { 'Content-Type': 'text/plain' });
75+
res.end('Proxy error: ' + (err && err.message ? err.message : 'Unknown error'));
76+
}
77+
}
4678
}
4779
// For local development use `proxy.localhost.conf js` .
4880
];

proxy.localhost.conf.js

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,5 +40,37 @@ module.exports = [
4040
res.end('Proxy error: ' + (err && err.message ? err.message : 'Unknown error'));
4141
}
4242
}
43+
},
44+
{
45+
context: ['/external-nationalid'],
46+
target: 'https://apis.mifos.community',
47+
pathRewrite: { '^/external-nationalid': '/1.0/nationalid' },
48+
changeOrigin: true,
49+
secure: true,
50+
logLevel: 'debug',
51+
onProxyReq: function (proxyReq, req, res) {
52+
// Inject API key server-side (same as nginx proxy_set_header in production)
53+
const apiKey = process.env.EXTERNAL_NATIONAL_ID_SYSTEM_API_KEY || '';
54+
if (apiKey) {
55+
proxyReq.setHeader('X-Gravitee-Api-Key', apiKey);
56+
}
57+
const rewrittenPath = (req.url || '').replace(/^\/external-nationalid/, '/1.0/nationalid');
58+
console.log('[Proxy] Proxying:', req.method, req.url, '->', this.target + rewrittenPath);
59+
},
60+
onError: function (err, req, res) {
61+
console.error(
62+
'[Proxy] Error while proxying request:',
63+
req && req.method,
64+
req && req.url,
65+
'->',
66+
this.target,
67+
'-',
68+
err && err.message
69+
);
70+
if (res && !res.headersSent) {
71+
res.writeHead(502, { 'Content-Type': 'text/plain' });
72+
res.end('Proxy error: ' + (err && err.message ? err.message : 'Unknown error'));
73+
}
74+
}
4375
}
4476
];

src/app/clients/client-stepper/client-general-step/client-general-step.component.html

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -39,9 +39,22 @@
3939
<mat-form-field class="flex-fill flex-23">
4040
<mat-label>{{ 'labels.inputs.External Id' | translate }}</mat-label>
4141
<input matInput formControlName="externalId" />
42+
@if (!externalNationalIdService.isLoading && externalNationalIdService.statusMessageKey) {
43+
<mat-hint>
44+
<span
45+
class="external-id-status"
46+
[ngClass]="{
47+
success: externalNationalIdService.statusMessageKey === 'External ID verified successfully',
48+
error: externalNationalIdService.statusMessageKey !== 'External ID verified successfully'
49+
}"
50+
>
51+
{{ 'labels.inputs.' + externalNationalIdService.statusMessageKey | translate }}
52+
</span>
53+
</mat-hint>
54+
}
4255
</mat-form-field>
4356

44-
@if (createClientForm.contains('fullname')) {
57+
@if (createClientForm.get('fullname')) {
4558
<mat-form-field class="flex-48">
4659
<mat-label>
4760
{{ 'labels.inputs.' + getDateLabel(createClientForm.value.legalFormId, ['Name', 'Entity Name']) | translate }}
@@ -62,13 +75,9 @@
6275
</mat-form-field>
6376
}
6477

65-
@if (
66-
createClientForm.contains('firstname') ||
67-
createClientForm.contains('middlename') ||
68-
createClientForm.contains('lastname')
69-
) {
78+
@if (createClientForm.get('firstname') || createClientForm.get('middlename') || createClientForm.get('lastname')) {
7079
<div class="name-fields-row">
71-
@if (createClientForm.contains('firstname')) {
80+
@if (createClientForm.get('firstname')) {
7281
<mat-form-field class="name-field first-name">
7382
<mat-label>{{ 'labels.inputs.First Name' | translate }}</mat-label>
7483
<input matInput required formControlName="firstname" />
@@ -87,7 +96,7 @@
8796
}
8897
</mat-form-field>
8998
}
90-
@if (createClientForm.contains('middlename')) {
99+
@if (createClientForm.get('middlename')) {
91100
<mat-form-field class="name-field middle-name">
92101
<mat-label>{{ 'labels.inputs.Middle Name' | translate }}</mat-label>
93102
<input matInput formControlName="middlename" />
@@ -100,7 +109,7 @@
100109
}
101110
</mat-form-field>
102111
}
103-
@if (createClientForm.contains('lastname')) {
112+
@if (createClientForm.get('lastname')) {
104113
<mat-form-field class="name-field last-name">
105114
<mat-label>{{ 'labels.inputs.Last Name' | translate }}</mat-label>
106115
<input matInput required formControlName="lastname" />

src/app/clients/client-stepper/client-general-step/client-general-step.component.scss

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,23 @@ mat-checkbox {
123123
}
124124
}
125125

126+
.external-id-status {
127+
font-size: 12px;
128+
font-weight: 500;
129+
130+
&.loading {
131+
color: #1976d2;
132+
}
133+
134+
&.success {
135+
color: #388e3c;
136+
}
137+
138+
&.error {
139+
color: #d32f2f;
140+
}
141+
}
142+
126143
@media (width <= 480px) {
127144
form {
128145
padding: 8px 0;

src/app/clients/client-stepper/client-general-step/client-general-step.component.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { filter, switchMap, takeUntil } from 'rxjs/operators';
2020
import { ClientsService } from 'app/clients/clients.service';
2121
import { Dates } from 'app/core/utils/dates';
2222
import { LegalFormId } from 'app/clients/models/legal-form.enum';
23+
import { ExternalNationalIdService } from 'app/clients/services/external-national-id.service';
2324

2425
/** Custom Services */
2526
import { SettingsService } from 'app/settings/settings.service';
@@ -37,6 +38,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
3738
selector: 'mifosx-client-general-step',
3839
templateUrl: './client-general-step.component.html',
3940
styleUrls: ['./client-general-step.component.scss'],
41+
providers: [ExternalNationalIdService],
4042
imports: [
4143
...STANDALONE_SHARED_IMPORTS,
4244
MatDivider,
@@ -52,6 +54,7 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy {
5254
private dateUtils = inject(Dates);
5355
private settingsService = inject(SettingsService);
5456
private clientService = inject(ClientsService);
57+
externalNationalIdService = inject(ExternalNationalIdService);
5558

5659
/** Subject to trigger unsubscription on destroy */
5760
private destroy$ = new Subject<void>();
@@ -104,6 +107,7 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy {
104107
this.maxDate = this.settingsService.businessDate;
105108
this.setOptions();
106109
this.buildDependencies();
110+
this.externalNationalIdService.watchExternalId(this.createClientForm, this.genderOptions);
107111
}
108112

109113
/**

0 commit comments

Comments
 (0)