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
+167
View File
@@ -0,0 +1,167 @@
/**
* 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/.
*/
export interface ProofType {
id: string | number;
name: string;
code?: string;
sides?: number;
category?: string;
documentKeyLabel?: string;
documentKeyPlaceholder?: string;
requiresDate?: boolean;
info?: string;
}
/**
* Client-side enrichment for KYC proof types. Backend currently returns
* only { id, name, description (code), position, active, mandatory } — we
* derive sides / category / banner text / input labels from the code.
*/
export const PROOF_META: Record<string, Partial<ProofType>> = {
PAN: {
sides: 1,
documentKeyLabel: 'PAN number',
documentKeyPlaceholder: 'e.g. ABCDE1234F',
info: 'PAN card — upload front side only (single copy required).'
},
AADHAAR: {
sides: 2,
documentKeyLabel: '12-digit Aadhaar number',
documentKeyPlaceholder: 'XXXX XXXX XXXX',
category: 'Government-issued ID',
info: 'Aadhaar — both front and back copies mandatory as per UIDAI guidelines.'
},
PASSPORT: {
sides: 2,
documentKeyLabel: 'Passport number',
documentKeyPlaceholder: 'e.g. A1234567',
category: 'Government-issued ID',
info: 'Passport — upload front (photo page) and back (last page with address).'
},
VOTER_ID: {
sides: 2,
documentKeyLabel: 'EPIC number',
documentKeyPlaceholder: 'Voter ID number',
category: 'Government-issued ID',
info: 'Voter ID (EPIC) — both sides required as address appears on the back.'
},
DRIVING_LICENSE: {
sides: 2,
documentKeyLabel: 'Driving licence number',
documentKeyPlaceholder: 'DL number',
category: 'Government-issued ID',
info: 'Driving licence — both sides required; vehicle class and validity are on the back.'
},
NREGA_JOB_CARD: {
sides: 1,
documentKeyLabel: 'NREGA job card number',
documentKeyPlaceholder: 'Job card number',
info: 'NREGA job card — ensure government authority signature is visible.'
},
NPR_LETTER: {
sides: 1,
documentKeyLabel: 'Reference / letter number',
documentKeyPlaceholder: 'NPR reference number',
info: 'NPR letter — upload full document.'
},
UTILITY_BILL: {
sides: 1,
category: 'Utility bills',
requiresDate: true,
info: 'Utility bill — must not be older than 3 months.'
},
ELECTRICITY_BILL: {
sides: 1,
category: 'Utility bills',
requiresDate: true,
info: 'Electricity bill — must not be older than 3 months.'
},
WATER_BILL: {
sides: 1,
category: 'Utility bills',
requiresDate: true,
info: 'Water bill — must not be older than 3 months.'
},
GAS_BILL: {
sides: 1,
category: 'Utility bills',
requiresDate: true,
info: 'Gas bill — must not be older than 3 months.'
},
INTERNET_BILL: {
sides: 1,
category: 'Utility bills',
requiresDate: true,
info: 'Internet bill — must not be older than 3 months.'
},
POSTPAID_MOBILE_BILL: {
sides: 1,
category: 'Utility bills',
requiresDate: true,
info: 'Postpaid mobile bill — must not be older than 3 months.'
},
BANK_STATEMENT: {
sides: 1,
category: 'Financial & other',
requiresDate: true,
info: 'Bank statement / passbook — not older than 3 months.'
},
RENT_AGREEMENT: {
sides: 1,
category: 'Financial & other',
info: 'Registered rent agreement — upload full document. Registration stamp must be visible.'
},
PROPERTY_TAX_RECEIPT: {
sides: 1,
category: 'Financial & other',
requiresDate: true,
info: 'Property tax receipt — latest receipt required.'
},
EMPLOYER_LETTER: {
sides: 1,
category: 'Financial & other',
info: 'Employer letter — must be on official letterhead with HR/management signature.'
},
PENSION_PAYMENT_ORDER: {
sides: 1,
category: 'Financial & other',
info: 'Pension payment order — upload full document issued by pension authority.'
}
};
export function normalizeProofType(p: any): ProofType {
const code = (p.code || p.description || '').toString().toUpperCase();
const meta = PROOF_META[code] || {};
return {
id: p.id,
name: p.name,
code,
sides: p.sides || meta.sides || 1,
category: p.category || meta.category || 'Other',
documentKeyLabel: p.documentKeyLabel || meta.documentKeyLabel || 'Document number',
documentKeyPlaceholder: p.documentKeyPlaceholder || meta.documentKeyPlaceholder || '',
requiresDate: p.requiresDate ?? meta.requiresDate ?? false,
info: p.info || meta.info || ''
};
}
export function groupProofsByCategory(proofs: ProofType[]): { label: string; items: ProofType[] }[] {
const map = new Map<string, ProofType[]>();
proofs.forEach((p) => {
const key = p.category || 'Other';
if (!map.has(key)) map.set(key, []);
map.get(key)!.push(p);
});
return Array.from(map.entries()).map(
([
label,
items
]) => ({ label, items })
);
}
@@ -0,0 +1,102 @@
/**
* 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 { Injectable, inject } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { ImageCropperDialogComponent } from '../clients-view/custom-dialogs/image-cropper-dialog/image-cropper-dialog.component';
@Injectable({ providedIn: 'root' })
export class KycUploadHelperService {
private dialog = inject(MatDialog);
/**
* If the picked file is an image, open the cropper dialog and resolve with
* the cropped File (or null if cancelled). Non-image files (PDFs) are
* passed through unchanged.
*/
pickWithCrop(file: File, title: string): Promise<File | null> {
if (!file.type.startsWith('image/')) {
return Promise.resolve(file);
}
return new Promise((resolve) => {
const ref = this.dialog.open(ImageCropperDialogComponent, {
width: '720px',
maxWidth: '95vw',
data: { file, title }
});
ref.afterClosed().subscribe((cropped: File | null) => resolve(cropped || null));
});
}
/**
* Stack two images (front above back) into one PNG. Resolves with both
* the new File (suitable for upload) and a data-URL preview for the UI.
* Returns null if either side is not an image (e.g. one is a PDF).
*/
mergeFrontBack(front: File, back: File, name: string): Promise<{ file: File; preview: string } | null> {
if (!front.type.startsWith('image/') || !back.type.startsWith('image/')) {
return Promise.resolve(null);
}
return Promise.all([
this.fileToImage(front),
this.fileToImage(back)
]).then(
([
fImg,
bImg
]) => {
const targetWidth = Math.max(fImg.width, bImg.width);
const fScale = targetWidth / fImg.width;
const bScale = targetWidth / bImg.width;
const fH = fImg.height * fScale;
const bH = bImg.height * bScale;
const gap = 20;
const totalH = Math.round(fH + bH + gap);
const canvas = document.createElement('canvas');
canvas.width = targetWidth;
canvas.height = totalH;
const ctx = canvas.getContext('2d');
if (!ctx) return null;
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(fImg, 0, 0, targetWidth, fH);
ctx.drawImage(bImg, 0, fH + gap, targetWidth, bH);
return new Promise<{ file: File; preview: string } | null>((resolve) => {
canvas.toBlob((blob) => {
if (!blob) {
resolve(null);
return;
}
const file = new File([blob], `${name}-combined.png`, { type: 'image/png' });
const preview = canvas.toDataURL('image/png');
resolve({ file, preview });
}, 'image/png');
});
}
);
}
private fileToImage(file: File): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const url = URL.createObjectURL(file);
const img = new Image();
img.onload = () => {
URL.revokeObjectURL(url);
resolve(img);
};
img.onerror = () => {
URL.revokeObjectURL(url);
reject(new Error('image load failed'));
};
img.src = url;
});
}
}