feat: MFA/2FA, OTP audit logs, KYC + client/user enhancements

- Access Control module: SMS/Email/TOTP/Hardware-token MFA factor management
- OTP Audit Logs module under Admin menu (list + detail) with 401/403 handling
- Two-factor authentication login flow updates
- Client KYC, India address, family-members, ckyc, nominee enhancements
- New shared KYC helpers (kyc-proof-types, kyc-upload-helper.service)
- User create/edit/view enhancements
- Shell navigation: OTP Audit Logs entry under real Admin menu
This commit is contained in:
Aditya
2026-05-10 13:24:48 +05:30
parent 2d4fb4237e
commit 3b752dc6ae
56 changed files with 7741 additions and 599 deletions
@@ -27,7 +27,7 @@ import {
UntypedFormControl,
ReactiveFormsModule
} from '@angular/forms';
import { Subject } from 'rxjs';
import { Subject, Subscription, merge } from 'rxjs';
import { filter, switchMap, takeUntil } from 'rxjs/operators';
import { ClientsService } from 'app/clients/clients.service';
import { Dates } from 'app/core/utils/dates';
@@ -340,6 +340,8 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
sanctionsBlocked = false;
/** Screening log ID from sanctions API */
screeningLogId: number | null = null;
/** Snapshot of the name that was last verified — used to invalidate verification when name changes */
private verifiedName: string = '';
/**
* @param {FormBuilder} formBuilder Form Builder
@@ -500,6 +502,7 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
}
if (data.nameVerified === true || data.nameVerified === 'true' || data.nameVerified === 1) {
this.sanctionsVerified = true;
this.verifiedName = this.currentNameSnapshot();
} else if (data.nameVerified === false || data.nameVerified === 'false' || data.nameVerified === 0) {
this.sanctionsVerified = false;
}
@@ -701,6 +704,8 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
})
);
}
// Re-attach name-change watchers after controls are (re)created
setTimeout(() => this.watchNameChangesForReverification());
});
// Initialize legal form — use client's existing value in edit mode, default to PERSON for create
const initialLegalForm = this.clientData?.legalForm?.id || LegalFormId.PERSON;
@@ -901,20 +906,7 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
* If a match is found, sanctionsBlocked is set to true blocking further steps.
*/
verifySanctions() {
const firstname = this.createClientForm.get('firstname')?.value || '';
const middlename = this.createClientForm.get('middlename')?.value || '';
const lastname = this.createClientForm.get('lastname')?.value || '';
const fullname = this.createClientForm.get('fullname')?.value || '';
const name =
fullname ||
[
firstname,
middlename,
lastname
]
.filter(Boolean)
.join(' ');
const name = this.currentNameSnapshot();
const dialogRef = this.dialog.open(SanctionsVerifyDialogComponent, {
width: '550px',
@@ -927,14 +919,62 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
// CLEAR — no match found
this.sanctionsVerified = true;
this.sanctionsBlocked = false;
this.verifiedName = name;
} else if (result.verified === false) {
// FLAGGED — match found, block account opening
this.sanctionsVerified = false;
this.sanctionsBlocked = true;
this.verifiedName = '';
}
});
}
/** Concatenated current name from the form (handles individual + entity) */
private currentNameSnapshot(): string {
const firstname = this.createClientForm.get('firstname')?.value || '';
const middlename = this.createClientForm.get('middlename')?.value || '';
const lastname = this.createClientForm.get('lastname')?.value || '';
const fullname = this.createClientForm.get('fullname')?.value || '';
return (
fullname ||
[
firstname,
middlename,
lastname
]
.filter(Boolean)
.join(' ')
).trim();
}
/**
* Watch name fields — once a name has been verified, any edit invalidates the
* sanctions verification badge so the user must re-verify before submitting.
*/
private nameWatchSub: Subscription | null = null;
private watchNameChangesForReverification() {
this.nameWatchSub?.unsubscribe();
const fields = [
'firstname',
'middlename',
'lastname',
'fullname'
];
const controls = fields.map((f) => this.createClientForm.get(f)).filter((c) => !!c);
if (!controls.length) return;
this.nameWatchSub = merge(...controls.map((c) => c!.valueChanges))
.pipe(takeUntil(this.destroy$))
.subscribe(() => {
// Only invalidate if a verification was previously recorded
if (!this.sanctionsVerified && !this.sanctionsBlocked) return;
if (this.currentNameSnapshot() !== this.verifiedName) {
this.sanctionsVerified = false;
this.sanctionsBlocked = false;
this.screeningLogId = null;
}
});
}
/** Search existing clients to select as guardian */
onGuardianSearch(text: string): void {
this.guardianSearchText = text;
@@ -947,7 +987,7 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
this.guardianSearching = true;
this.guardianSearchTimer = setTimeout(() => {
this.clientService.searchByText(text, 0, 10).subscribe({
this.clientService.searchByText(text, 0, 10, '', '', true).subscribe({
next: (data: any) => {
this.guardianSearchResults = data?.content || [];
this.guardianSearching = false;
@@ -1106,7 +1146,7 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
}
this.signatorySearching = true;
this.signatorySearchTimer = setTimeout(() => {
this.clientService.searchByText(text, 0, 10).subscribe({
this.clientService.searchByText(text, 0, 10, '', '', true).subscribe({
next: (data: any) => {
this.signatorySearchResults = data?.content || [];
this.signatorySearching = false;
@@ -1623,8 +1663,19 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
const generalDetails = this.readOnly ? this.createClientForm.getRawValue() : this.createClientForm.value;
const dateFormat = this.settingsService.dateFormat;
const locale = this.settingsService.language.code;
const isEditMode = !!this.clientData;
for (const key in generalDetails) {
if (generalDetails[key] === '' || key === 'addSavings') {
if (key === 'addSavings') {
delete generalDetails[key];
continue;
}
if (generalDetails[key] === '') {
// In edit mode, preserve empty strings for fields that previously had a value so the
// backend can clear them (Fineract treats a missing field as "no change" but an empty
// string as a deliberate clear).
if (isEditMode && this.clientData[key] != null && this.clientData[key] !== '') {
continue;
}
delete generalDetails[key];
}
}