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
@@ -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;
});
}
}