+
@if (photographPreview) {
@@ -1023,15 +1158,25 @@
} @else {
-
Client Photograph
+
+ Client Photograph
+ *
+
JPG / PNG, max 2MB
Choose & Crop
+ @if (photoSigTouched) {
+
Photograph is required
+ }
}
-
+
@if (signaturePreview) {
@@ -1047,12 +1192,18 @@
} @else {
-
Specimen Signature
+
+ Specimen Signature
+ *
+
Scanned image, max 1MB
Choose & Crop
+ @if (photoSigTouched) {
+
Signature is required
+ }
}
diff --git a/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.scss b/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.scss
index 058940306..cc38a02a0 100644
--- a/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.scss
+++ b/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.scss
@@ -23,6 +23,19 @@
color: var(--md-sys-color-primary, #1074b9);
font-size: 14px;
}
+
+ .section-required-tag {
+ margin-left: 6px;
+ padding: 2px 9px;
+ border-radius: 999px;
+ background: #fee2e2;
+ color: #b91c1c;
+ border: 1px solid #fecaca;
+ font-size: 10px;
+ font-weight: 700;
+ letter-spacing: 0.4px;
+ text-transform: uppercase;
+ }
}
.kyc-badge {
@@ -948,12 +961,23 @@
border-radius: 10px;
background: #f9fafb;
text-align: center;
- transition: border-color 0.15s;
+ transition:
+ border-color 0.15s,
+ background 0.15s;
&:hover {
border-color: #1353a0;
}
+ &--missing {
+ border-color: #dc2626;
+ background: #fef2f2;
+
+ &:hover {
+ border-color: #b91c1c;
+ }
+ }
+
&__icon {
width: 44px;
height: 44px;
@@ -972,6 +996,19 @@
color: #0f172a;
}
+ &__req {
+ color: #dc2626;
+ font-weight: 700;
+ margin-left: 2px;
+ }
+
+ &__error {
+ margin-top: 6px;
+ font-size: 12px;
+ font-weight: 600;
+ color: #b91c1c;
+ }
+
&__hint {
font-size: 11px;
color: #94a3b8;
@@ -1214,3 +1251,393 @@
font-weight: 600;
}
}
+
+/* ===================== KYC Document chip-grouped picker ===================== */
+.kyc-doc-pickbox {
+ border: 1px solid #e2e8f0;
+ border-radius: 14px;
+ background: #fff;
+ padding: 16px 18px;
+ margin: 6px 0 14px;
+ box-shadow: 0 1px 3px rgb(15 23 42 / 4%);
+
+ &__head {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding-bottom: 10px;
+ border-bottom: 1px solid #f1f5f9;
+ margin-bottom: 14px;
+ }
+
+ &__icon {
+ color: #2563eb;
+ background: #eff6ff;
+ border-radius: 999px;
+ width: 36px;
+ height: 36px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 20px;
+ flex-shrink: 0;
+ }
+
+ &__titles {
+ min-width: 0;
+ }
+
+ &__title {
+ margin: 0;
+ font-size: 14px;
+ font-weight: 700;
+ color: #0f172a;
+ }
+
+ &__sub {
+ margin: 2px 0 0;
+ font-size: 12px;
+ color: #64748b;
+ }
+}
+
+.kyc-doc-field {
+ margin-bottom: 4px;
+}
+
+.kyc-doc-label {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 13px;
+ font-weight: 600;
+ color: #334155;
+ margin-bottom: 10px;
+
+ mat-icon {
+ font-size: 18px;
+ width: 18px;
+ height: 18px;
+ color: #64748b;
+ }
+}
+
+.kyc-doc-chip-wrap {
+ border: 1px solid #e2e8f0;
+ border-radius: 12px;
+ padding: 14px 16px;
+ background: #f8fafc;
+}
+
+.kyc-doc-chip-group-label {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 11px;
+ font-weight: 700;
+ letter-spacing: 0.6px;
+ text-transform: uppercase;
+ color: #64748b;
+ margin-bottom: 8px;
+
+ mat-icon {
+ font-size: 14px;
+ width: 14px;
+ height: 14px;
+ color: #94a3b8;
+ }
+}
+
+.kyc-doc-chip-grid {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+}
+
+.kyc-doc-chip-divider {
+ height: 1px;
+ background: #e2e8f0;
+ margin: 14px 0 10px;
+}
+
+.kyc-doc-chip {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 7px 14px;
+ border-radius: 999px;
+ background: #fff;
+ border: 1px solid #cbd5e1;
+ font-size: 13px;
+ color: #334155;
+ cursor: pointer;
+ user-select: none;
+ transition:
+ background 0.15s,
+ border-color 0.15s,
+ color 0.15s,
+ box-shadow 0.15s;
+
+ &:hover {
+ border-color: #1976d2;
+ color: #1976d2;
+ }
+
+ &__icon {
+ font-size: 16px;
+ width: 16px;
+ height: 16px;
+ color: #94a3b8;
+ }
+
+ &--sel {
+ background: #e3f2fd;
+ border-color: #1976d2;
+ color: #0d47a1;
+ font-weight: 600;
+ box-shadow: inset 0 0 0 1px #1976d2;
+
+ .kyc-doc-chip__icon {
+ color: #1976d2;
+ }
+ }
+
+ &:focus-visible {
+ outline: 2px solid #1976d2;
+ outline-offset: 2px;
+ }
+}
+
+.kyc-doc-banner {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 10px 14px;
+ margin-top: 12px;
+ border-radius: 10px;
+ background: #ecfdf5;
+ border: 1px solid #a7f3d0;
+ color: #065f46;
+ font-size: 13px;
+
+ mat-icon {
+ font-size: 18px;
+ width: 18px;
+ height: 18px;
+ color: #059669;
+ flex-shrink: 0;
+ }
+}
+
+.kyc-doc-error {
+ margin-top: 8px;
+ color: #b91c1c;
+ font-size: 12px;
+ padding-left: 4px;
+
+ strong {
+ font-weight: 700;
+ }
+}
+
+/* ===================== KYC Document upload dropzones ===================== */
+.kyc-doc-upload-grid {
+ display: grid;
+ grid-template-columns: 1fr;
+ gap: 12px;
+ margin-top: 14px;
+
+ &--two {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+
+ @media (width <= 720px) {
+ grid-template-columns: 1fr !important;
+ }
+}
+
+.kyc-doc-upload-slot {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.kyc-doc-upload-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+}
+
+.kyc-doc-upload-title {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 13px;
+ font-weight: 600;
+ color: #334155;
+
+ mat-icon {
+ font-size: 16px;
+ width: 16px;
+ height: 16px;
+ color: #64748b;
+ }
+}
+
+.kyc-doc-upload-req {
+ display: inline-flex;
+ align-items: center;
+ padding: 2px 9px;
+ border-radius: 999px;
+ background: #fee2e2;
+ color: #b91c1c;
+ border: 1px solid #fecaca;
+ font-size: 10px;
+ font-weight: 700;
+ letter-spacing: 0.4px;
+ text-transform: uppercase;
+}
+
+.kyc-merged {
+ margin-top: 16px;
+ border: 1px solid #e2e8f0;
+ border-radius: 12px;
+ background: #fff;
+ padding: 14px 16px;
+
+ &__head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ flex-wrap: wrap;
+ gap: 6px 16px;
+ margin-bottom: 10px;
+ }
+
+ &__title {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 13px;
+ font-weight: 700;
+ color: #0f172a;
+
+ mat-icon {
+ font-size: 18px;
+ width: 18px;
+ height: 18px;
+ color: #2563eb;
+ }
+ }
+
+ &__hint {
+ font-size: 12px;
+ color: #64748b;
+ }
+
+ &__preview {
+ display: flex;
+ justify-content: center;
+ background: #f8fafc;
+ border: 1px solid #e2e8f0;
+ border-radius: 10px;
+ padding: 12px;
+ overflow: hidden;
+
+ img {
+ max-width: 100%;
+ max-height: 360px;
+ border-radius: 6px;
+ box-shadow: 0 1px 6px rgb(15 23 42 / 8%);
+ }
+ }
+
+ &__actions {
+ display: flex;
+ gap: 10px;
+ flex-wrap: wrap;
+ margin-top: 12px;
+ }
+
+ &__btn {
+ mat-icon {
+ font-size: 16px;
+ width: 16px;
+ height: 16px;
+ margin-right: 4px;
+ vertical-align: middle;
+ }
+ }
+}
+
+.kyc-doc-dropzone {
+ display: block;
+ border: 2px dashed #cbd5e1;
+ border-radius: 12px;
+ background: #f8fafc;
+ padding: 18px 14px;
+ cursor: pointer;
+ transition:
+ background 0.15s,
+ border-color 0.15s;
+
+ &:hover {
+ background: #f1f5f9;
+ border-color: #94a3b8;
+ }
+
+ &--req {
+ border-color: #fca5a5;
+ background: #fef2f2;
+ }
+
+ &--has {
+ border-style: solid;
+ border-color: #86efac;
+ background: #f0fdf4;
+ }
+
+ &__hint {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ color: #64748b;
+ font-size: 13px;
+
+ mat-icon {
+ font-size: 22px;
+ width: 22px;
+ height: 22px;
+ color: #94a3b8;
+ }
+ }
+
+ &__file {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ min-width: 0;
+
+ mat-icon {
+ color: #16a34a;
+ flex-shrink: 0;
+ }
+ }
+
+ &__name {
+ flex: 1;
+ min-width: 0;
+ font-size: 13px;
+ font-weight: 600;
+ color: #0f172a;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ &__remove {
+ flex-shrink: 0;
+ }
+}
diff --git a/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.ts b/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.ts
index 288384984..379f15e6f 100644
--- a/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.ts
+++ b/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.ts
@@ -8,19 +8,28 @@
import { Component, Input, OnInit, OnChanges, OnDestroy, SimpleChanges, inject } from '@angular/core';
import { UntypedFormBuilder, UntypedFormGroup, Validators, FormsModule } from '@angular/forms';
-import { MatDialog, MAT_DIALOG_DATA, MatDialogContent, MatDialogActions } from '@angular/material/dialog';
+import {
+ MatDialog,
+ MAT_DIALOG_DATA,
+ MatDialogContent,
+ MatDialogActions,
+ MatDialogClose
+} from '@angular/material/dialog';
import { Inject } from '@angular/core';
import { MatProgressSpinner } from '@angular/material/progress-spinner';
import { MatSelectTrigger } from '@angular/material/select';
import { MatDivider } from '@angular/material/divider';
import { MatIcon } from '@angular/material/icon';
+import { MatIconButton, MatButton } from '@angular/material/button';
import { MatCheckbox } from '@angular/material/checkbox';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { ClientsService } from '../../clients.service';
import { SettingsService } from 'app/settings/settings.service';
import { Dates } from 'app/core/utils/dates';
+import { ProofType, normalizeProofType, groupProofsByCategory } from '../../shared/kyc-proof-types';
+import { KycUploadHelperService } from '../../shared/kyc-upload-helper.service';
@Component({
selector: 'mifosx-client-kyc-step',
@@ -31,6 +40,8 @@ import { Dates } from 'app/core/utils/dates';
FormsModule,
MatDivider,
MatIcon,
+ MatIconButton,
+ MatButton,
MatCheckbox,
MatProgressSpinner,
FaIconComponent,
@@ -43,6 +54,7 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
private clientsService = inject(ClientsService);
private settingsService = inject(SettingsService);
private dateUtils = inject(Dates);
+ private kycUpload = inject(KycUploadHelperService);
/** Set by parent after client is created */
@Input() clientId: string = '';
@@ -67,15 +79,47 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
/** KYC Document Types — populated from API template, falls back to defaults */
kycDocumentTypes: { name: string; code: string; mandatory: boolean }[] = [];
+
+ /** Chip-group state for KYC document picker (mirrors identity proof in identifier dialog) */
+ kycDocProofs: ProofType[] = [];
+ kycDocGroups: { label: string; items: ProofType[] }[] = [];
+ selectedKycDoc: ProofType | null = null;
+ /** Front/back files for the chosen KYC document */
+ kycDocFiles: { front: File | null; back: File | null } = { front: null, back: null };
+ /** Combined front+back PNG (built automatically once both image sides are ready) */
+ kycDocMergedFile: File | null = null;
+ kycDocMergedPreview: string | null = null;
+
+ /** Set true after a save attempt so we can highlight missing photo/signature boxes */
+ photoSigTouched = false;
+
+ /** Either an in-memory crop or an image already on the server */
+ get hasPhoto(): boolean {
+ return !!this.photographPreview;
+ }
+
+ get hasSignature(): boolean {
+ return !!this.signaturePreview;
+ }
+
+ /** Photo + signature are mandatory before leaving the KYC tab */
+ get photoSigComplete(): boolean {
+ return this.hasPhoto && this.hasSignature;
+ }
+
+ /** Mark uploads as touched so the UI shows the missing-required state */
+ markPhotoSigTouched(): void {
+ this.photoSigTouched = true;
+ }
/** Fallback document types when template API is unavailable */
private defaultDocumentTypes = [
- 'Aadhaar Card',
- 'Passport',
- 'PAN Card',
- 'Driving Licence',
- 'Voter ID',
- 'NREGA Job Card',
- 'National Population Register Letter'
+ { name: 'Aadhaar Card', code: 'AADHAAR' },
+ { name: 'Passport', code: 'PASSPORT' },
+ { name: 'PAN Card', code: 'PAN' },
+ { name: 'Driving Licence', code: 'DRIVING_LICENSE' },
+ { name: 'Voter ID', code: 'VOTER_ID' },
+ { name: 'NREGA Job Card', code: 'NREGA_JOB_CARD' },
+ { name: 'National Population Register Letter', code: 'NPR_LETTER' }
];
/** Constitution-based KYC document types for non-individual clients */
private constitutionDocTypes: Record
= {
@@ -371,6 +415,16 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
});
ngOnInit(): void {
+ // Seed defaults immediately so the picker has options before clientId is set
+ // (template API requires clientId; user can still pick a type pre-save).
+ if (!this.kycDocumentTypes.length) {
+ this.kycDocumentTypes = this.defaultDocumentTypes.map((d) => ({
+ name: d.name,
+ code: d.code,
+ mandatory: false
+ }));
+ this.rebuildKycDocPicker();
+ }
this.loadExistingKycIfNeeded();
this.loadKycTemplate();
if (this.isNonIndividual && this.constitutionMeta) {
@@ -412,9 +466,111 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
// Reset selected document type since options changed
this.kycForm.get('kycDocumentName')?.setValue('');
this.kycForm.get('kycDocumentName')?.updateValueAndValidity();
+ this.rebuildKycDocPicker();
}
}
+ /** Rebuild chip-group picker model from kycDocumentTypes */
+ private rebuildKycDocPicker(): void {
+ this.kycDocProofs = (this.kycDocumentTypes || []).map((d, i) =>
+ normalizeProofType({ id: d.code || d.name || i, name: d.name, code: d.code, description: d.code || d.name })
+ );
+ this.kycDocGroups = groupProofsByCategory(this.kycDocProofs);
+ // Sync selected proof with current form value (e.g. on edit-mode prefill)
+ const currentName = this.kycForm?.get('kycDocumentName')?.value;
+ this.selectedKycDoc = currentName ? this.kycDocProofs.find((p) => p.name === currentName) || null : null;
+ }
+
+ selectKycDoc(proof: ProofType): void {
+ this.selectedKycDoc = proof;
+ this.kycForm.get('kycDocumentName')?.setValue(proof.name);
+ // Reset uploads + merged preview when document type changes
+ this.kycDocFiles = { front: null, back: null };
+ this.kycDocMergedFile = null;
+ this.kycDocMergedPreview = null;
+ }
+
+ isKycDocSelected(proof: ProofType): boolean {
+ return this.selectedKycDoc?.code === proof.code && this.selectedKycDoc?.name === proof.name;
+ }
+
+ async onKycDocFile(event: Event, slot: 'front' | 'back'): Promise {
+ const input = event.target as HTMLInputElement;
+ const raw = input.files?.[0];
+ input.value = ''; // allow re-picking the same filename
+ if (!raw) return;
+ if (raw.size > 5 * 1024 * 1024) return;
+
+ const cropped = await this.kycUpload.pickWithCrop(raw, `Crop ${slot} side`);
+ if (!cropped) return;
+ this.kycDocFiles[slot] = cropped;
+ await this.refreshKycDocMerged();
+ }
+
+ async reCropKycDocFile(slot: 'front' | 'back'): Promise {
+ const current = this.kycDocFiles[slot];
+ if (!current || !current.type.startsWith('image/')) return;
+ const cropped = await this.kycUpload.pickWithCrop(current, `Re-crop ${slot} side`);
+ if (!cropped) return;
+ this.kycDocFiles[slot] = cropped;
+ await this.refreshKycDocMerged();
+ }
+
+ removeKycDocFile(slot: 'front' | 'back'): void {
+ this.kycDocFiles[slot] = null;
+ this.kycDocMergedFile = null;
+ this.kycDocMergedPreview = null;
+ }
+
+ private async refreshKycDocMerged(): Promise {
+ const proof = this.selectedKycDoc;
+ if (!proof || proof.sides !== 2 || !this.kycDocFiles.front || !this.kycDocFiles.back) {
+ this.kycDocMergedFile = null;
+ this.kycDocMergedPreview = null;
+ return;
+ }
+ const merged = await this.kycUpload.mergeFrontBack(
+ this.kycDocFiles.front,
+ this.kycDocFiles.back,
+ proof.name || 'kyc-document'
+ );
+ if (merged) {
+ this.kycDocMergedFile = merged.file;
+ this.kycDocMergedPreview = merged.preview;
+ } else {
+ this.kycDocMergedFile = null;
+ this.kycDocMergedPreview = null;
+ }
+ }
+
+ /**
+ * Files + proof metadata for the parent to persist as a client identifier.
+ * When both image sides are present they're sent as a single combined PNG;
+ * otherwise the individual files are returned.
+ */
+ get kycDocPayload(): {
+ proof: ProofType | null;
+ proofId: any;
+ documentKey: string;
+ files: { slot: string; file: File }[];
+ } {
+ const files: { slot: string; file: File }[] = [];
+ if (this.selectedKycDoc?.sides === 2 && this.kycDocMergedFile) {
+ files.push({ slot: 'Combined', file: this.kycDocMergedFile });
+ } else {
+ if (this.kycDocFiles.front) files.push({ slot: 'Front', file: this.kycDocFiles.front });
+ if (this.selectedKycDoc?.sides === 2 && this.kycDocFiles.back) {
+ files.push({ slot: 'Back', file: this.kycDocFiles.back });
+ }
+ }
+ return {
+ proof: this.selectedKycDoc,
+ proofId: this.selectedKycDoc?.id ?? '',
+ documentKey: this.kycForm.get('kycDocumentNumber')?.value || '',
+ files
+ };
+ }
+
/** Load existing KYC data if clientId is set (edit mode) */
private loadExistingKycIfNeeded(): void {
if (!this.clientId || this.kycLoaded) return;
@@ -469,6 +625,10 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
form15Type: data.form15Type || '15G',
form15FY: data.form15FY || ''
});
+ // Sync chip-picker selection with the prefilled document name
+ if (data.kycDocumentName) {
+ this.selectedKycDoc = this.kycDocProofs.find((p) => p.name === data.kycDocumentName) || null;
+ }
// Set verification statuses from existing data
if (data.panVerified) {
this.panVerifyStatus = 'VERIFIED';
@@ -568,16 +728,18 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
this.kycForm.get('kycDocumentName')?.setValidators(Validators.required);
}
this.kycForm.get('kycDocumentName')?.updateValueAndValidity();
+ this.rebuildKycDocPicker();
}
},
error: () => {
// Fallback to defaults if template API unavailable
this.kycTemplateLoaded = true;
- this.kycDocumentTypes = this.defaultDocumentTypes.map((name) => ({
- name,
- code: name,
+ this.kycDocumentTypes = this.defaultDocumentTypes.map((d) => ({
+ name: d.name,
+ code: d.code,
mandatory: false
}));
+ this.rebuildKycDocPicker();
}
});
}
@@ -1232,6 +1394,7 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
MatIcon,
MatDialogContent,
MatDialogActions,
+ MatDialogClose,
STANDALONE_SHARED_IMPORTS
]
})
diff --git a/src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.html b/src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.html
index 8a428867d..6c0cb5670 100644
--- a/src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.html
+++ b/src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.html
@@ -77,6 +77,74 @@
}
diff --git a/src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.scss b/src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.scss
index ba29cd983..66d527c48 100644
--- a/src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.scss
+++ b/src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.scss
@@ -214,6 +214,179 @@
}
}
+/* ===================== KYC Verification status ===================== */
+.kyc-status-card {
+ border: 1px solid #e2e8f0;
+ border-radius: 14px;
+ background: #fff;
+ margin-bottom: 18px;
+ overflow: hidden;
+ box-shadow: 0 1px 3px rgb(15 23 42 / 4%);
+
+ &--complete {
+ border-color: #bbf7d0;
+ background: linear-gradient(180deg, #f0fdf4 0%, #fff 60%);
+ }
+
+ &__head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ padding: 14px 18px;
+ border-bottom: 1px solid #eef2f7;
+ }
+
+ &__title {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ font-size: 14px;
+ font-weight: 700;
+ color: #0f172a;
+ }
+
+ &__icon {
+ color: #2563eb;
+ font-size: 16px;
+ }
+
+ &--complete &__icon {
+ color: #16a34a;
+ }
+
+ &__badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 5px 12px;
+ border-radius: 20px;
+ font-size: 11px;
+ font-weight: 700;
+ letter-spacing: 0.4px;
+ text-transform: uppercase;
+
+ fa-icon {
+ font-size: 11px;
+ }
+
+ &--ok {
+ background: #dcfce7;
+ color: #166534;
+ border: 1px solid #bbf7d0;
+ }
+
+ &--partial {
+ background: #fef3c7;
+ color: #92400e;
+ border: 1px solid #fde68a;
+ }
+
+ &--pending {
+ background: #fee2e2;
+ color: #991b1b;
+ border: 1px solid #fecaca;
+ }
+ }
+
+ &__body {
+ padding: 6px 18px 14px;
+ }
+}
+
+.kyc-status-row {
+ display: grid;
+ grid-template-columns: 140px 1fr auto;
+ align-items: center;
+ gap: 14px;
+ padding: 10px 0;
+ border-bottom: 1px dashed #eef2f7;
+
+ &:last-child {
+ border-bottom: none;
+ }
+
+ &__label {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 13px;
+ font-weight: 600;
+ color: #475569;
+
+ fa-icon {
+ color: #64748b;
+ font-size: 13px;
+ }
+ }
+
+ &__value {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ min-width: 0;
+
+ &--missing {
+ color: #94a3b8;
+ font-style: italic;
+ font-size: 13px;
+ }
+ }
+
+ &__id {
+ font-family: 'SF Mono', 'Cascadia Code', Consolas, monospace;
+ font-size: 13px;
+ font-weight: 600;
+ color: #0f172a;
+ word-break: break-all;
+ }
+
+ &__name {
+ font-size: 12px;
+ color: #64748b;
+ }
+}
+
+.kyc-pill {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 4px 10px;
+ border-radius: 20px;
+ font-size: 11px;
+ font-weight: 700;
+ letter-spacing: 0.3px;
+ white-space: nowrap;
+
+ fa-icon {
+ font-size: 11px;
+ }
+
+ &--ok {
+ background: #dcfce7;
+ color: #166534;
+ border: 1px solid #bbf7d0;
+ }
+
+ &--pending {
+ background: #f1f5f9;
+ color: #64748b;
+ border: 1px solid #e2e8f0;
+ }
+}
+
+@media (width <= 640px) {
+ .kyc-status-row {
+ grid-template-columns: 110px 1fr;
+ grid-template-rows: auto auto;
+
+ .kyc-pill {
+ grid-column: 2 / 3;
+ justify-self: start;
+ }
+ }
+}
+
/* ===================== Client info grid ===================== */
.ckyc-info-grid {
display: grid;
diff --git a/src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.ts b/src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.ts
index 26a2c6b17..8c7ecca1a 100644
--- a/src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.ts
+++ b/src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.ts
@@ -67,6 +67,18 @@ export class CkycTabComponent implements OnInit, OnDestroy {
ckycBatchId = '';
checkingExisting = false;
+ /** KYC verification status (Aadhaar + PAN) — independent of CERSAI sync */
+ panVerified = false;
+ aadhaarVerified = false;
+ panHolderName = '';
+ aadhaarHolderName = '';
+ panNumber = '';
+ aadhaarMasked = '';
+
+ get kycComplete(): boolean {
+ return this.panVerified && this.aadhaarVerified;
+ }
+
/** Pre-existing artefacts detected from client data */
existing: Record<'ADDRESS_PROOF' | 'PHOTO_PROOF' | 'CLIENT_IMAGE' | 'CLIENT_SIGNATURE', ExistingArtefact> = {
ADDRESS_PROOF: { present: false },
@@ -170,6 +182,13 @@ export class CkycTabComponent implements OnInit, OnDestroy {
this.ckycNumber = data?.ckycNumber || '';
this.ckycUploadedOn = this.formatJavaDate(data?.ckycUploadedOn);
this.ckycBatchId = data?.ckycBatchId || '';
+ this.panVerified = data?.panVerified === true || data?.panVerified === 'true' || data?.panVerified === 1;
+ this.aadhaarVerified =
+ data?.aadhaarVerified === true || data?.aadhaarVerified === 'true' || data?.aadhaarVerified === 1;
+ this.panHolderName = data?.panFullName || data?.panHolderName || '';
+ this.aadhaarHolderName = data?.aadhaarHolderName || '';
+ this.panNumber = data?.panNumber || '';
+ this.aadhaarMasked = data?.aadhaarNumberMasked || '';
});
}
diff --git a/src/app/clients/clients-view/custom-dialogs/upload-document-dialog/upload-document-dialog.component.ts b/src/app/clients/clients-view/custom-dialogs/upload-document-dialog/upload-document-dialog.component.ts
index e52728d1c..22697da08 100644
--- a/src/app/clients/clients-view/custom-dialogs/upload-document-dialog/upload-document-dialog.component.ts
+++ b/src/app/clients/clients-view/custom-dialogs/upload-document-dialog/upload-document-dialog.component.ts
@@ -21,138 +21,10 @@ import { FileUploadComponent } from '../../../../shared/file-upload/file-upload.
import { MatIconButton } from '@angular/material/button';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { ClientsService } from '../../../clients.service';
-
-interface ProofType {
- id: string | number;
- name: string;
- code?: string;
- sides?: number;
- category?: string;
- documentKeyLabel?: string;
- documentKeyPlaceholder?: string;
- requiresDate?: boolean;
- info?: string;
-}
+import { ProofType, normalizeProofType, groupProofsByCategory } from '../../../shared/kyc-proof-types';
type SectionKey = 'id' | 'address';
-/**
- * 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.
- */
-const PROOF_META: Record
> = {
- 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.'
- }
-};
-
@Component({
selector: 'mifosx-upload-document-dialog',
templateUrl: './upload-document-dialog.component.html',
@@ -245,9 +117,9 @@ export class UploadDocumentDialogComponent implements OnInit {
this.clientsService.getKycProofTypes().subscribe({
next: (res: any) => {
const activeFilter = (p: any) => p?.active !== false;
- this.idProofs = (res?.idProofs || []).filter(activeFilter).map((p: any) => this.normalizeProof(p));
- this.addressProofs = (res?.addressProofs || []).filter(activeFilter).map((p: any) => this.normalizeProof(p));
- this.buildAddressGroups();
+ this.idProofs = (res?.idProofs || []).filter(activeFilter).map(normalizeProofType);
+ this.addressProofs = (res?.addressProofs || []).filter(activeFilter).map(normalizeProofType);
+ this.addressGroups = groupProofsByCategory(this.addressProofs);
this.loadingProofs = false;
},
error: () => {
@@ -256,37 +128,6 @@ export class UploadDocumentDialogComponent implements OnInit {
});
}
- private normalizeProof(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 || ''
- };
- }
-
- private buildAddressGroups(): void {
- const map = new Map();
- this.addressProofs.forEach((p) => {
- const key = p.category || 'Other';
- if (!map.has(key)) map.set(key, []);
- map.get(key).push(p);
- });
- this.addressGroups = Array.from(map.entries()).map(
- ([
- label,
- items
- ]) => ({ label, items })
- );
- }
-
onIdProofChange(proofId: any): void {
this.selectedIdProof = this.idProofs.find((p) => String(p.id) === String(proofId)) || null;
this.idFiles = { front: null, back: null };
diff --git a/src/app/clients/clients-view/nominee-tab/nominee-tab.component.ts b/src/app/clients/clients-view/nominee-tab/nominee-tab.component.ts
index eb6e4a236..99731db5f 100644
--- a/src/app/clients/clients-view/nominee-tab/nominee-tab.component.ts
+++ b/src/app/clients/clients-view/nominee-tab/nominee-tab.component.ts
@@ -294,7 +294,7 @@ export class NomineeTabComponent implements OnInit {
}
this.searching = true;
this.searchTimer = setTimeout(() => {
- this.clientsService.searchByText(text, 0, 8).subscribe({
+ this.clientsService.searchByText(text, 0, 8, '', '', true).subscribe({
next: (data: any) => {
this.searchResults = data?.content || [];
this.searching = false;
@@ -412,7 +412,7 @@ export class NomineeTabComponent implements OnInit {
}
this.guardianSearching = true;
this.guardianSearchTimer = setTimeout(() => {
- this.clientsService.searchByText(text, 0, 8).subscribe({
+ this.clientsService.searchByText(text, 0, 8, '', '', true).subscribe({
next: (data: any) => {
this.guardianSearchResults = data?.content || [];
this.guardianSearching = false;
diff --git a/src/app/clients/clients.service.ts b/src/app/clients/clients.service.ts
index de9b603a2..3b7e201c4 100644
--- a/src/app/clients/clients.service.ts
+++ b/src/app/clients/clients.service.ts
@@ -576,11 +576,20 @@ export class ClientsService {
return this.http.get(`/clients/${clientId}/collaterals/template`);
}
- searchByText(text: string, page: number, pageSize: number, sortAttribute: string = '', sortDirection: string = '') {
+ searchByText(
+ text: string,
+ page: number,
+ pageSize: number,
+ sortAttribute: string = '',
+ sortDirection: string = '',
+ activeOnly: boolean = false
+ ) {
+ const innerRequest: any = { text };
+ if (activeOnly) {
+ innerRequest.activeOnly = true;
+ }
let request: any = {
- request: {
- text
- },
+ request: innerRequest,
page,
size: pageSize
};
diff --git a/src/app/clients/create-client/create-client.component.ts b/src/app/clients/create-client/create-client.component.ts
index 08f539a97..21571913f 100644
--- a/src/app/clients/create-client/create-client.component.ts
+++ b/src/app/clients/create-client/create-client.component.ts
@@ -72,9 +72,9 @@ export class CreateClientComponent {
@ViewChild(ClientGeneralStepComponent) clientGeneralStep: ClientGeneralStepComponent;
@ViewChild('clientAddress') clientAddressStep: ClientAddressStepComponent;
@ViewChild('clientIndiaAddress') clientIndiaAddressStep: ClientIndiaAddressStepComponent;
- @ViewChild('clientEntityKyc') clientEntityKycStep: ClientEntityKycStepComponent;
+ @ViewChild(ClientEntityKycStepComponent) clientEntityKycStep: ClientEntityKycStepComponent;
@ViewChild('clientKyc') clientKycStep: ClientKycStepComponent;
- @ViewChild('clientFamilyMembers') clientFamilyMembersStep: ClientFamilyMembersStepComponent;
+ @ViewChild(ClientFamilyMembersStepComponent) clientFamilyMembersStep: ClientFamilyMembersStepComponent;
@ViewChildren('dtclient') clientDatatables: QueryList;
datatables: any = [];
@@ -250,6 +250,13 @@ export class CreateClientComponent {
// KYC tab
const kycTabIndex = 1;
if (tabIndex === kycTabIndex) {
+ // Photograph + Signature are mandatory before leaving the KYC tab
+ if (this.clientKycStep && !this.clientKycStep.photoSigComplete) {
+ this.clientKycStep.markPhotoSigTouched();
+ this.validationError = 'Photograph and Signature are required before continuing.';
+ return;
+ }
+ this.validationError = '';
// Save KYC
if (this.clientKycStep?.hasKycData && !this.clientKycStep.kycId) {
this.clientsService.saveClientKyc(clientId, this.clientKycStep.kycData).subscribe({ error: () => {} });
@@ -265,6 +272,8 @@ export class CreateClientComponent {
.uploadClientSignatureImage(clientId, this.clientKycStep.signatureFile)
.subscribe({ error: () => {} });
}
+ // Persist the KYC document picker selection + uploaded files as a client identifier
+ this.saveKycDocProof(clientId);
return;
}
@@ -349,6 +358,14 @@ export class CreateClientComponent {
validationError = '';
submit() {
+ // Validate: photograph and signature are mandatory
+ if (this.clientKycStep && !this.clientKycStep.photoSigComplete) {
+ this.clientKycStep.markPhotoSigTouched();
+ this.validationError = 'Photograph and Signature are required before submitting.';
+ // Switch to KYC tab
+ this.selectedTabIndex = 1;
+ return;
+ }
// Validate: at least one family member required (Individual clients)
if (this.legalFormType === 1 && this.clientFamilyMembersStep && !this.clientFamilyMembersStep.hasFamilyMembers) {
this.validationError = 'At least one family member is required. Please add a family member before submitting.';
@@ -425,6 +442,9 @@ export class CreateClientComponent {
this.saveClientFamilyMembers(clientId);
}
+ // Persist the KYC document picker selection + uploaded files as a client identifier
+ this.saveKycDocProof(clientId);
+
// Save KYC only if it wasn't already created during the KYC step
if (this.clientKycStep?.hasKycData && !this.clientKycStep.kycId) {
this.clientsService.saveClientKyc(clientId, this.clientKycStep.kycData).subscribe({
@@ -514,6 +534,72 @@ export class CreateClientComponent {
},
error: () => {}
});
+
+ // Persist the selected proof of address as a client identifier (with attached files)
+ this.saveAddressProof(clientId);
+ }
+
+ /**
+ * Save the address-proof picked on the permanent tab as a client identifier
+ * + upload each selected file as a document attached to that identifier.
+ * Mirrors identities-tab.createIdentifierWithDocs.
+ */
+ private saveAddressProof(clientId: string) {
+ const payload = this.clientIndiaAddressStep?.addressProofPayload;
+ if (!payload?.proofId) return;
+
+ const proofName = payload.proof?.name || 'Address Proof';
+ const identifierData: any = {
+ documentTypeId: payload.proofId,
+ status: 'Active',
+ documentKey: payload.documentKey || `${proofName} - ${clientId}`,
+ description: payload.issueDate ? `Issue date: ${payload.issueDate}` : ''
+ };
+
+ this.clientsService.addClientIdentifier(clientId, identifierData).subscribe({
+ next: (res: any) => {
+ const identifierId = res?.resourceId;
+ if (!identifierId) return;
+ for (const entry of payload.files) {
+ const formData = new FormData();
+ formData.append('name', `${proofName} — ${entry.slot}`);
+ formData.append('file', entry.file);
+ this.clientsService.uploadClientIdentifierDocument(identifierId, formData).subscribe({ error: () => {} });
+ }
+ },
+ error: () => {}
+ });
+ }
+
+ /**
+ * Save the KYC document picked on the KYC tab as a client identifier
+ * + upload each selected file as a document attached to that identifier.
+ */
+ private saveKycDocProof(clientId: string) {
+ const payload = this.clientKycStep?.kycDocPayload;
+ if (!payload?.proofId || !payload?.files?.length) return;
+
+ const proofName = payload.proof?.name || 'KYC Document';
+ const identifierData: any = {
+ documentTypeId: payload.proofId,
+ status: 'Active',
+ documentKey: payload.documentKey || `${proofName} - ${clientId}`,
+ description: 'KYC document'
+ };
+
+ this.clientsService.addClientIdentifier(clientId, identifierData).subscribe({
+ next: (res: any) => {
+ const identifierId = res?.resourceId;
+ if (!identifierId) return;
+ for (const entry of payload.files) {
+ const formData = new FormData();
+ formData.append('name', `${proofName} — ${entry.slot}`);
+ formData.append('file', entry.file);
+ this.clientsService.uploadClientIdentifierDocument(identifierId, formData).subscribe({ error: () => {} });
+ }
+ },
+ error: () => {}
+ });
}
/**
diff --git a/src/app/clients/edit-client/edit-client.component.html b/src/app/clients/edit-client/edit-client.component.html
index 9dcdd46df..c63b1a3cb 100644
--- a/src/app/clients/edit-client/edit-client.component.html
+++ b/src/app/clients/edit-client/edit-client.component.html
@@ -67,8 +67,8 @@
warning
- Re-KYC Required: Date of birth was changed. Please re-verify PAN and update the remarks
- below before proceeding.
+ Re-KYC Required: Client name or date of birth was changed. Please re-verify PAN and
+ update the remarks below before proceeding.
}
@@ -180,6 +180,8 @@
[addressData]="clientIndiaAddressStep?.addressData"
[guardian]="clientGeneralStep?.selectedGuardian"
[guardianRelation]="clientGeneralStep?.createClientForm?.get('guardianDetails.guardianRelation')?.value || ''"
+ [entityClassificationData]="clientEntityKycStep?.classificationData"
+ [familyMembers]="clientFamilyMembersStep?.clientFamilyMembers || []"
[maritalStatusId]="clientGeneralStep?.createClientForm?.get('maritalStatusId')?.value"
[maritalStatusOptions]="clientGeneralStep?.maritalStatusOptions || []"
[educationLevelOptions]="clientGeneralStep?.educationLevelOptions || []"
diff --git a/src/app/clients/edit-client/edit-client.component.ts b/src/app/clients/edit-client/edit-client.component.ts
index 92f579192..c4a09aed4 100644
--- a/src/app/clients/edit-client/edit-client.component.ts
+++ b/src/app/clients/edit-client/edit-client.component.ts
@@ -69,9 +69,9 @@ export class EditClientComponent {
@ViewChild(ClientGeneralStepComponent) clientGeneralStep: ClientGeneralStepComponent;
@ViewChild('clientAddress') clientAddressStep: ClientAddressStepComponent;
@ViewChild('clientIndiaAddress') clientIndiaAddressStep: ClientIndiaAddressStepComponent;
- @ViewChild('clientEntityKyc') clientEntityKycStep: ClientEntityKycStepComponent;
+ @ViewChild(ClientEntityKycStepComponent) clientEntityKycStep: ClientEntityKycStepComponent;
@ViewChild('clientKyc') clientKycStep: ClientKycStepComponent;
- @ViewChild('clientFamilyMembers') clientFamilyMembersStep: ClientFamilyMembersStepComponent;
+ @ViewChild(ClientFamilyMembersStepComponent) clientFamilyMembersStep: ClientFamilyMembersStepComponent;
@ViewChildren('dtclient') clientDatatables: QueryList;
datatables: any = [];
@@ -184,13 +184,13 @@ export class EditClientComponent {
let dialogContext = 'Are you sure you want to save the changes';
let dialogType = 'basic';
- // Check if DOB changed on the General tab — warn about re-KYC
+ // Check if name or DOB changed on the General tab — warn about re-KYC
if (this.selectedTabIndex === 0) {
const changes = this.detectSensitiveFieldChanges(this.clientDataAndTemplate);
- if (changes.dobChanged) {
+ if (changes.nameChanged || changes.dobChanged) {
heading = 'Re-KYC Required';
dialogContext =
- 'Date of birth has been changed. This will require PAN re-verification in the KYC section. A remark will be added automatically. Do you want to proceed';
+ 'Client name or date of birth has been changed. This will require PAN re-verification in the KYC section. A remark will be added automatically. Do you want to proceed';
dialogType = 'warn';
}
}
@@ -232,11 +232,11 @@ export class EditClientComponent {
// Log mobile/email changes as client notes for risk audit
this.logFieldChangeNotes(changedFields);
- // If DOB changed, flag re-KYC requirement and reset PAN verification
- if (changedFields.dobChanged) {
+ // If name or DOB changed, flag re-KYC requirement and reset PAN verification
+ if (changedFields.nameChanged || changedFields.dobChanged) {
this.dobChanged = true;
this.tabEditMode[1] = true; // auto-enable KYC tab for re-verification
- this.flagReKycRequired(changedFields.oldDob, changedFields.newDob);
+ this.flagReKycRequired(changedFields);
}
// Update original data snapshot so subsequent edits compare against latest
@@ -249,6 +249,13 @@ export class EditClientComponent {
if (changedFields.dobChanged) {
originalData.dateOfBirth = changedFields.newDob;
}
+ if (changedFields.nameChanged) {
+ const form = this.clientGeneralForm;
+ originalData.firstname = form?.get('firstname')?.value ?? originalData.firstname;
+ originalData.middlename = form?.get('middlename')?.value ?? originalData.middlename;
+ originalData.lastname = form?.get('lastname')?.value ?? originalData.lastname;
+ originalData.fullname = form?.get('fullname')?.value ?? originalData.fullname;
+ }
this.selectedTabIndex = this.selectedTabIndex + 1;
},
@@ -261,6 +268,11 @@ export class EditClientComponent {
// KYC tab (always index 1)
if (tabIndex === 1) {
+ // Photograph + Signature are mandatory before leaving the KYC tab
+ if (this.clientKycStep && !this.clientKycStep.photoSigComplete) {
+ this.clientKycStep.markPhotoSigTouched();
+ return;
+ }
this.savingClient = true;
// Upload photo if selected
@@ -317,6 +329,8 @@ export class EditClientComponent {
if (!kycId && res?.resourceId) {
this.clientKycStep.kycId = res.resourceId.toString();
}
+ // Persist KYC document picker selection + uploaded files as a client identifier
+ this.saveKycDocProof(this.clientId);
this.selectedTabIndex = this.selectedTabIndex + 1;
},
error: () => {
@@ -363,7 +377,7 @@ export class EditClientComponent {
}
/**
- * Compare current form values with original data to detect changes in DOB, mobile, email.
+ * Compare current form values with original data to detect changes in name, DOB, mobile, email.
*/
private detectSensitiveFieldChanges(originalData: any) {
const form = this.clientGeneralForm;
@@ -381,10 +395,38 @@ export class EditClientComponent {
? oldDobRaw.toISOString().split('T')[0]
: oldDobRaw || '';
+ const buildName = (parts: { firstname?: string; middlename?: string; lastname?: string; fullname?: string }) =>
+ (
+ parts.fullname ||
+ [
+ parts.firstname,
+ parts.middlename,
+ parts.lastname
+ ]
+ .filter((p) => p && p.trim())
+ .join(' ')
+ ).trim();
+
+ const newName = buildName({
+ firstname: form?.get('firstname')?.value || '',
+ middlename: form?.get('middlename')?.value || '',
+ lastname: form?.get('lastname')?.value || '',
+ fullname: form?.get('fullname')?.value || ''
+ });
+ const oldName = buildName({
+ firstname: originalData.firstname || '',
+ middlename: originalData.middlename || '',
+ lastname: originalData.lastname || '',
+ fullname: originalData.fullname || ''
+ });
+
return {
+ nameChanged: newName !== oldName,
mobileChanged: newMobile !== oldMobile,
emailChanged: newEmail !== oldEmail,
dobChanged: newDob !== oldDob,
+ oldName,
+ newName,
oldMobile,
newMobile,
oldEmail,
@@ -412,22 +454,38 @@ export class EditClientComponent {
}
/**
- * When DOB changes, reset PAN verification in KYC step and pre-fill remark for re-KYC.
+ * When name or DOB changes, reset PAN verification in KYC step and pre-fill remark for re-KYC.
*/
- private flagReKycRequired(oldDob: string, newDob: string) {
+ private flagReKycRequired(changes: {
+ nameChanged: boolean;
+ dobChanged: boolean;
+ oldName: string;
+ newName: string;
+ oldDob: string;
+ newDob: string;
+ }) {
+ const reasons: string[] = [];
+ if (changes.nameChanged) {
+ reasons.push(`Client name changed from "${changes.oldName}" to "${changes.newName}"`);
+ }
+ if (changes.dobChanged) {
+ reasons.push(`Date of birth changed from "${changes.oldDob}" to "${changes.newDob}"`);
+ }
+ const reasonText = reasons.join('; ');
+
// Reset PAN verification status so user must re-verify
if (this.clientKycStep) {
this.clientKycStep.panVerifyStatus = 'NONE';
this.clientKycStep.panVerifyName = '';
this.clientKycStep.panVerifiedOn = '';
// Pre-fill remarks with re-KYC note
- const remarkText = `[Re-KYC Required] Date of birth changed from ${oldDob} to ${newDob}. PAN re-verification is mandatory.`;
+ const remarkText = `[Re-KYC Required] ${reasonText}. PAN re-verification is mandatory.`;
this.clientKycStep.kycForm.get('remarks')?.setValue(remarkText);
}
- // Also log DOB change as an audit note
+ // Also log the change as an audit note
const timestamp = new Date().toISOString();
- const note = `[AUDIT] Date of birth changed from "${oldDob}" to "${newDob}" on ${timestamp}. Re-KYC (PAN re-verification) required.`;
+ const note = `[AUDIT] ${reasonText} on ${timestamp}. Re-KYC (PAN re-verification) required.`;
this.clientsService.createClientNote(this.clientId, { note }).subscribe({ error: () => {} });
}
@@ -439,6 +497,13 @@ export class EditClientComponent {
* Submits the edit client form.
*/
submit() {
+ // Photograph + Signature are mandatory before submitting
+ if (this.clientKycStep && !this.clientKycStep.photoSigComplete) {
+ this.clientKycStep.markPhotoSigTouched();
+ this.selectedTabIndex = 1;
+ return;
+ }
+
const locale = this.settingsService.language.code;
const dateFormat = this.settingsService.dateFormat;
const clientData = {
@@ -496,10 +561,14 @@ export class EditClientComponent {
kycObs = this.clientsService.saveClientKyc(this.clientId, kycData);
}
kycObs.subscribe({
- next: () => navigateBack(),
+ next: () => {
+ this.saveKycDocProof(this.clientId);
+ navigateBack();
+ },
error: () => navigateBack()
});
} else {
+ this.saveKycDocProof(this.clientId);
navigateBack();
}
},
@@ -580,6 +649,72 @@ export class EditClientComponent {
},
error: () => {}
});
+
+ // Persist the selected proof of address as a client identifier (with attached files)
+ this.saveAddressProof(clientId);
+ }
+
+ /**
+ * Save the address-proof picked on the permanent tab as a client identifier
+ * + upload each selected file as a document attached to that identifier.
+ * Mirrors identities-tab.createIdentifierWithDocs.
+ */
+ private saveAddressProof(clientId: string) {
+ const payload = this.clientIndiaAddressStep?.addressProofPayload;
+ if (!payload?.proofId) return;
+
+ const proofName = payload.proof?.name || 'Address Proof';
+ const identifierData: any = {
+ documentTypeId: payload.proofId,
+ status: 'Active',
+ documentKey: payload.documentKey || `${proofName} - ${clientId}`,
+ description: payload.issueDate ? `Issue date: ${payload.issueDate}` : ''
+ };
+
+ this.clientsService.addClientIdentifier(clientId, identifierData).subscribe({
+ next: (res: any) => {
+ const identifierId = res?.resourceId;
+ if (!identifierId) return;
+ for (const entry of payload.files) {
+ const formData = new FormData();
+ formData.append('name', `${proofName} — ${entry.slot}`);
+ formData.append('file', entry.file);
+ this.clientsService.uploadClientIdentifierDocument(identifierId, formData).subscribe({ error: () => {} });
+ }
+ },
+ error: () => {}
+ });
+ }
+
+ /**
+ * Save the KYC document picked on the KYC tab as a client identifier
+ * + upload each selected file as a document attached to that identifier.
+ */
+ private saveKycDocProof(clientId: string) {
+ const payload = this.clientKycStep?.kycDocPayload;
+ if (!payload?.proofId || !payload?.files?.length) return;
+
+ const proofName = payload.proof?.name || 'KYC Document';
+ const identifierData: any = {
+ documentTypeId: payload.proofId,
+ status: 'Active',
+ documentKey: payload.documentKey || `${proofName} - ${clientId}`,
+ description: 'KYC document'
+ };
+
+ this.clientsService.addClientIdentifier(clientId, identifierData).subscribe({
+ next: (res: any) => {
+ const identifierId = res?.resourceId;
+ if (!identifierId) return;
+ for (const entry of payload.files) {
+ const formData = new FormData();
+ formData.append('name', `${proofName} — ${entry.slot}`);
+ formData.append('file', entry.file);
+ this.clientsService.uploadClientIdentifierDocument(identifierId, formData).subscribe({ error: () => {} });
+ }
+ },
+ error: () => {}
+ });
}
private saveClientFamilyMembers(clientId: string) {
diff --git a/src/app/clients/shared/kyc-proof-types.ts b/src/app/clients/shared/kyc-proof-types.ts
new file mode 100644
index 000000000..b2351fcbc
--- /dev/null
+++ b/src/app/clients/shared/kyc-proof-types.ts
@@ -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> = {
+ 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();
+ 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 })
+ );
+}
diff --git a/src/app/clients/shared/kyc-upload-helper.service.ts b/src/app/clients/shared/kyc-upload-helper.service.ts
new file mode 100644
index 000000000..235a7e67b
--- /dev/null
+++ b/src/app/clients/shared/kyc-upload-helper.service.ts
@@ -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 {
+ 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 {
+ 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;
+ });
+ }
+}
diff --git a/src/app/core/authentication/credentials.model.ts b/src/app/core/authentication/credentials.model.ts
index 597517cac..098729cad 100644
--- a/src/app/core/authentication/credentials.model.ts
+++ b/src/app/core/authentication/credentials.model.ts
@@ -23,6 +23,9 @@ export interface Credentials {
roles: any;
userId: number;
username: string;
+ /** Optional contact details — populated when the auth response includes them. */
+ email?: string;
+ mobileNo?: string;
shouldRenewPassword: boolean;
rememberMe?: boolean;
}
diff --git a/src/app/core/shell/shell.component.html b/src/app/core/shell/shell.component.html
index 72c6c548f..8028aae7a 100644
--- a/src/app/core/shell/shell.component.html
+++ b/src/app/core/shell/shell.component.html
@@ -220,6 +220,9 @@
Templates
+
+ OTP Audit Logs
+
diff --git a/src/app/core/shell/toolbar/toolbar.component.html b/src/app/core/shell/toolbar/toolbar.component.html
index 5e59a8253..f73c1f5f5 100644
--- a/src/app/core/shell/toolbar/toolbar.component.html
+++ b/src/app/core/shell/toolbar/toolbar.component.html
@@ -342,6 +342,12 @@
{{ 'labels.menus.Profile' | translate }}
+
+
+
+
+ Access Control
+
diff --git a/src/app/login/login.component.html b/src/app/login/login.component.html
index bf74b35a4..8daacd944 100644
--- a/src/app/login/login.component.html
+++ b/src/app/login/login.component.html
@@ -253,9 +253,7 @@
@if (resetPassword) {
}
- @if (twoFactorAuthenticationRequired) {
-
- }
+
diff --git a/src/app/login/login.component.ts b/src/app/login/login.component.ts
index ebd460339..746454811 100644
--- a/src/app/login/login.component.ts
+++ b/src/app/login/login.component.ts
@@ -43,6 +43,7 @@ import { TenantSelectorComponent } from '../shared/tenant-selector/tenant-select
import { LoginFormComponent } from './login-form/login-form.component';
import { ResetPasswordComponent } from './reset-password/reset-password.component';
import { TwoFactorAuthenticationComponent } from './two-factor-authentication/two-factor-authentication.component';
+import { MatDialog, MatDialogRef } from '@angular/material/dialog';
import { MatMenuTrigger, MatMenu, MatMenuItem } from '@angular/material/menu';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
@@ -216,6 +217,8 @@ export class LoginComponent implements OnInit, OnDestroy {
private alertService = inject(AlertService);
private settingsService = inject(SettingsService);
private themingService = inject(ThemingService);
+ private dialog = inject(MatDialog);
+ private twoFactorDialogRef: MatDialogRef
| null = null;
private router = inject(Router);
private versionService = inject(VersionService);
@@ -270,13 +273,16 @@ export class LoginComponent implements OnInit, OnDestroy {
const alertType = alertEvent.type;
if (alertType === 'Password Expired') {
this.twoFactorAuthenticationRequired = false;
+ this.closeTwoFactorDialog();
this.resetPassword = true;
} else if (alertType === 'Two Factor Authentication Required') {
this.resetPassword = false;
this.twoFactorAuthenticationRequired = true;
+ this.openTwoFactorDialog();
} else if (alertType === 'Authentication Success') {
this.resetPassword = false;
this.twoFactorAuthenticationRequired = false;
+ this.closeTwoFactorDialog();
this.router.navigate(['/'], { replaceUrl: true });
} else if (alertType === 'Tenant Changed') {
this.updateLogo();
@@ -323,9 +329,34 @@ export class LoginComponent implements OnInit, OnDestroy {
if (this.theme$) {
this.theme$.unsubscribe();
}
+ this.closeTwoFactorDialog();
clearInterval(this.slideTimer);
}
+ /** Open the 2FA challenge as a non-dismissable modal */
+ private openTwoFactorDialog(): void {
+ if (this.twoFactorDialogRef) return;
+ this.twoFactorDialogRef = this.dialog.open(TwoFactorAuthenticationComponent, {
+ width: '440px',
+ maxWidth: '95vw',
+ panelClass: 'tfa-dialog-panel',
+ backdropClass: 'tfa-dialog-backdrop',
+ disableClose: true,
+ autoFocus: 'first-tabbable',
+ restoreFocus: true
+ });
+ this.twoFactorDialogRef.afterClosed().subscribe(() => {
+ this.twoFactorDialogRef = null;
+ });
+ }
+
+ private closeTwoFactorDialog(): void {
+ if (this.twoFactorDialogRef) {
+ this.twoFactorDialogRef.close();
+ this.twoFactorDialogRef = null;
+ }
+ }
+
goToSlide(index: number): void {
this.currentSlide = index;
this.restartSlider();
diff --git a/src/app/login/two-factor-authentication/two-factor-authentication.component.html b/src/app/login/two-factor-authentication/two-factor-authentication.component.html
index a233e7a48..6122beb42 100644
--- a/src/app/login/two-factor-authentication/two-factor-authentication.component.html
+++ b/src/app/login/two-factor-authentication/two-factor-authentication.component.html
@@ -6,74 +6,149 @@
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
-
- {{ 'labels.text.Two Factor Authentication' | translate }}
-
+
+
+
+
-
+
{{ 'labels.text.Two Factor Authentication' | translate }}
+
+ @if (!otpRequested) {
+ Choose how you want to receive your one-time passcode.
+ } @else {
+ Enter the 6-digit code we sent you to continue.
+ }
+
-
-@if (!otpRequested) {
-
{{ 'labels.text.Please select a delivery method' | translate }}:
+
+ @if (!otpRequested) {
+
-}
+
+ @if (loading) {
+
+ Sending…
+ } @else {
+
+ {{ 'labels.buttons.Request OTP' | translate }}
+ }
+
+
+ }
-
-@if (otpRequested) {
-
{{ 'labels.text.Please enter the OTP' | translate }}:
-
-
-}
+
+
+ @if (loading) {
+
+ Verifying…
+ } @else {
+
+ {{ 'labels.buttons.Validate OTP' | translate }}
+ }
+
+
+
+ @if (resendOTPLoading) {
+
+ Resending…
+ } @else {
+
+ {{ 'labels.buttons.Resend OTP' | translate }}
+ }
+
+
+ }
+
diff --git a/src/app/login/two-factor-authentication/two-factor-authentication.component.scss b/src/app/login/two-factor-authentication/two-factor-authentication.component.scss
index f7ad82c62..1728e0d4d 100644
--- a/src/app/login/two-factor-authentication/two-factor-authentication.component.scss
+++ b/src/app/login/two-factor-authentication/two-factor-authentication.component.scss
@@ -6,180 +6,338 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
-// ===== Host Container =====
:host {
display: flex;
- flex-direction: column;
width: 100%;
- animation: fade-in-up 0.4s ease-out;
+ justify-content: center;
+ animation: tfa-fade 0.4s ease-out;
}
-// ===== Title Section =====
-p {
- text-align: center;
- font-size: 1.1rem;
- color: rgb(255 255 255 / 90%);
- margin: 0 0 1rem;
-
- strong {
- font-weight: 600;
- color: inherit;
- }
-}
-
-// ===== Divider =====
-mat-divider {
- margin-bottom: 1.5rem;
-}
-
-// ===== Delivery Method Description =====
-p:not(:first-of-type) {
- text-align: center;
- font-size: 0.95rem;
- color: rgb(255 255 255 / 65%);
- margin-bottom: 1rem;
-}
-
-// ===== Main Form Container =====
-.two-factor-auth-form {
+.tfa-card {
+ width: 100%;
+ max-width: 400px;
+ padding: 24px 22px 22px;
+ border-radius: 18px;
+ background: rgb(255 255 255 / 6%);
+ border: 1px solid rgb(255 255 255 / 12%);
+ backdrop-filter: blur(8px);
display: flex;
flex-direction: column;
+ align-items: stretch;
+}
+
+.tfa-icon {
+ align-self: center;
+ width: 56px;
+ height: 56px;
+ border-radius: 16px;
+ background: linear-gradient(135deg, #2563eb 0%, #1e40af 100%);
+ color: #fff;
+ display: inline-flex;
align-items: center;
- width: 100%;
- gap: 1rem;
+ justify-content: center;
+ font-size: 24px;
+ box-shadow:
+ 0 6px 20px rgb(37 99 235 / 35%),
+ inset 0 1px 0 rgb(255 255 255 / 25%);
+ margin-bottom: 14px;
+}
- mat-radio-button {
- margin-bottom: 0.25rem;
+.tfa-title {
+ margin: 0 0 6px;
+ text-align: center;
+ font-size: 18px;
+ font-weight: 700;
+ color: #fff;
+ letter-spacing: 0.2px;
+}
- &:last-child {
- margin-bottom: 0;
- }
+.tfa-sub {
+ margin: 0 0 20px;
+ text-align: center;
+ font-size: 13px;
+ color: rgb(255 255 255 / 65%);
+ line-height: 1.5;
+}
- // Style radio buttons to match Material Design 3
- ::ng-deep {
- .mdc-form-field {
- font-size: 0.95rem;
- color: var(--md-sys-color-on-surface, #1a1c1e);
- }
+/* ──────── Form layout ──────── */
+.tfa-form {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
- .mdc-radio__outer-circle {
- border-color: var(--md-sys-color-on-surface-variant, #44474e);
- }
+/* ──────── Step 1 — delivery methods ──────── */
+.tfa-methods__group {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
- .mdc-radio--selected .mdc-radio__outer-circle,
- .mdc-radio--selected .mdc-radio__inner-circle {
- border-color: var(--md-sys-color-primary, #1074b9);
- }
- }
+.tfa-method {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 12px 14px;
+ border: 1px solid rgb(255 255 255 / 12%);
+ background: rgb(255 255 255 / 4%);
+ border-radius: 12px;
+ cursor: pointer;
+ transition:
+ border-color 0.18s,
+ background 0.18s,
+ box-shadow 0.18s;
+
+ &:hover {
+ border-color: rgb(96 165 250 / 60%);
+ background: rgb(96 165 250 / 8%);
}
- // ===== OTP Input Field =====
- .two-factor-auth-input {
- width: 100%;
- max-width: 320px;
-
- [matPrefix] {
- padding-left: 1rem;
-
- fa-icon {
- margin-right: 0.75rem;
- }
- }
-
- ::ng-deep {
- .mat-mdc-text-field-wrapper {
- border-radius: 12px 12px 0 0;
- }
-
- .mat-mdc-form-field-focus-overlay {
- border-radius: 12px 12px 0 0;
- background-color: var(--md-sys-color-on-surface, #1a1c1e);
- opacity: 0.04;
- }
-
- .mdc-line-ripple::after {
- border-bottom-color: var(--md-sys-color-primary, #1074b9);
- }
-
- .mat-mdc-form-field-hint-wrapper {
- padding: 0.5rem 1rem;
- }
-
- mat-hint {
- font-size: 0.75rem;
- color: var(--md-sys-color-on-surface-variant, #44474e);
- }
- }
+ &.is-selected {
+ border-color: #60a5fa;
+ background: rgb(96 165 250 / 12%);
+ box-shadow:
+ inset 0 0 0 1px #60a5fa,
+ 0 6px 18px rgb(37 99 235 / 28%);
}
- // ===== Buttons =====
- .two-factor-auth-button,
- [mat-raised-button],
- [mat-button] {
- width: 100%;
- max-width: 320px;
- height: 48px;
- border-radius: 24px;
- font-size: 0.95rem;
- font-weight: 500;
- letter-spacing: 0.02em;
- transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
- display: flex;
+ &__icon {
+ width: 36px;
+ height: 36px;
+ border-radius: 999px;
+ background: rgb(255 255 255 / 8%);
+ color: #93c5fd;
+ display: inline-flex;
align-items: center;
justify-content: center;
- gap: 0.5rem;
+ font-size: 14px;
+ flex-shrink: 0;
}
- [mat-raised-button] {
- background: var(--md-sys-color-primary, #1074b9);
- color: var(--md-sys-color-on-primary, #fff);
- box-shadow:
- 0 1px 2px rgb(0 0 0 / 30%),
- 0 1px 3px 1px rgb(0 0 0 / 15%);
-
- &:hover:not([disabled]) {
- box-shadow:
- 0 1px 3px rgb(0 0 0 / 30%),
- 0 4px 8px 3px rgb(0 0 0 / 15%);
- }
-
- &[disabled] {
- background: var(--md-sys-color-on-surface, #1a1c1e);
- opacity: 0.38;
- }
+ &.is-selected &__icon {
+ background: #1d4ed8;
+ color: #fff;
}
- [mat-button] {
- color: var(--md-sys-color-primary, #1074b9);
- background: transparent;
-
- &:hover:not([disabled]) {
- background: rgb(16 116 185 / 8%);
- }
+ &__body {
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+ flex: 1;
}
- mat-spinner {
- display: inline-block;
- margin-left: 0.5rem;
+ &__name {
+ font-size: 13px;
+ font-weight: 700;
+ color: rgb(255 255 255 / 92%);
+ text-transform: uppercase;
+ letter-spacing: 0.4px;
+ }
+
+ &__target {
+ font-size: 12.5px;
+ color: rgb(255 255 255 / 70%);
+ margin-top: 2px;
+ }
+
+ &__radio {
+ margin-left: auto;
+
+ ::ng-deep .mdc-radio__outer-circle {
+ border-color: rgb(255 255 255 / 35%);
+ }
+
+ ::ng-deep .mat-mdc-radio-button.mat-accent .mdc-radio__inner-circle,
+ ::ng-deep .mdc-radio--selected .mdc-radio__outer-circle {
+ border-color: #60a5fa !important;
+ }
}
}
-// ===== Radio Group Spacing =====
-.radio-group-spacing {
+/* ──────── Step 2 — context strip ──────── */
+.tfa-context {
display: flex;
- flex-direction: column;
align-items: center;
- gap: 0.75rem;
- width: 100%;
- padding: 0.5rem 0;
- margin-bottom: 0.5rem;
+ gap: 10px;
+ padding: 10px 14px;
+ margin-bottom: 16px;
+ border-radius: 12px;
+ background: rgb(255 255 255 / 5%);
+ border: 1px solid rgb(255 255 255 / 10%);
+
+ > fa-icon {
+ color: #93c5fd;
+ font-size: 16px;
+ flex-shrink: 0;
+ }
+
+ > div {
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ min-width: 0;
+ }
+
+ &__label {
+ font-size: 11px;
+ color: rgb(255 255 255 / 55%);
+ letter-spacing: 0.4px;
+ text-transform: uppercase;
+ }
+
+ &__target {
+ font-size: 13px;
+ font-weight: 700;
+ color: #fff;
+ word-break: break-all;
+ }
+
+ &__validity {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ padding: 4px 10px;
+ border-radius: 999px;
+ background: rgb(34 197 94 / 18%);
+ color: #86efac;
+ border: 1px solid rgb(34 197 94 / 35%);
+ font-size: 11px;
+ font-weight: 700;
+ flex-shrink: 0;
+
+ fa-icon {
+ font-size: 10px;
+ }
+ }
}
-// ===== Animations =====
-@keyframes fade-in-up {
+/* ──────── Step 2 — OTP input ──────── */
+.tfa-otp {
+ display: flex;
+ justify-content: center;
+}
+
+.tfa-otp__input {
+ width: 100%;
+ max-width: 320px;
+ height: 60px;
+ text-align: center;
+ border: 1px solid rgb(255 255 255 / 18%);
+ background: rgb(255 255 255 / 8%);
+ border-radius: 14px;
+ color: #fff;
+ font-family: 'SF Mono', 'Cascadia Code', Consolas, monospace;
+ font-size: 28px;
+ font-weight: 700;
+ letter-spacing: 14px;
+ padding: 0 0 0 14px;
+ outline: none;
+ transition:
+ border-color 0.18s,
+ box-shadow 0.18s,
+ background 0.18s;
+
+ &::placeholder {
+ color: rgb(255 255 255 / 25%);
+ letter-spacing: 14px;
+ }
+
+ &:focus {
+ border-color: #60a5fa;
+ background: rgb(255 255 255 / 12%);
+ box-shadow: 0 0 0 4px rgb(96 165 250 / 25%);
+ }
+}
+
+.tfa-error {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 6px;
+ padding: 8px 12px;
+ border-radius: 10px;
+ background: rgb(239 68 68 / 14%);
+ border: 1px solid rgb(239 68 68 / 35%);
+ color: #fecaca;
+ font-size: 12.5px;
+
+ fa-icon {
+ font-size: 13px;
+ color: #fca5a5;
+ }
+
+ strong {
+ color: #fff;
+ font-weight: 700;
+ }
+}
+
+/* ──────── Buttons ──────── */
+.tfa-btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ width: 100%;
+ height: 48px;
+ border-radius: 12px;
+ font-size: 14px;
+ font-weight: 700;
+ letter-spacing: 0.3px;
+ cursor: pointer;
+ transition:
+ background 0.18s,
+ box-shadow 0.18s,
+ transform 0.18s,
+ opacity 0.18s;
+ border: 1px solid transparent;
+
+ fa-icon {
+ font-size: 14px;
+ }
+
+ &:disabled {
+ cursor: not-allowed;
+ opacity: 0.6;
+ }
+
+ &--primary {
+ background: linear-gradient(135deg, #2563eb 0%, #1e40af 100%);
+ color: #fff;
+ box-shadow:
+ 0 6px 20px rgb(37 99 235 / 32%),
+ inset 0 1px 0 rgb(255 255 255 / 18%);
+
+ &:hover:not(:disabled) {
+ transform: translateY(-1px);
+ box-shadow:
+ 0 10px 24px rgb(37 99 235 / 42%),
+ inset 0 1px 0 rgb(255 255 255 / 22%);
+ }
+ }
+
+ &--ghost {
+ background: transparent;
+ color: rgb(255 255 255 / 80%);
+ border-color: rgb(255 255 255 / 18%);
+
+ &:hover:not(:disabled) {
+ background: rgb(255 255 255 / 8%);
+ color: #fff;
+ border-color: rgb(255 255 255 / 30%);
+ }
+ }
+
+ mat-spinner ::ng-deep circle {
+ stroke: #fff !important;
+ }
+}
+
+/* ──────── Animations ──────── */
+@keyframes tfa-fade {
from {
opacity: 0;
- transform: translateY(10px);
+ transform: translateY(8px);
}
to {
@@ -188,61 +346,16 @@ p:not(:first-of-type) {
}
}
-// ===== Dark Mode Support =====
-:host-context(.dark-theme),
-:host-context([data-theme='dark']) {
- p {
- color: var(--md-sys-color-on-surface, #e2e2e5);
-
- strong {
- color: inherit;
- }
+/* ──────── Responsive ──────── */
+@media (width <= 480px) {
+ .tfa-card {
+ padding: 20px 16px;
+ border-radius: 16px;
}
- p:not(:first-of-type) {
- color: var(--md-sys-color-on-surface-variant, #c4c6cf);
- }
-
- .two-factor-auth-form {
- mat-radio-button {
- ::ng-deep {
- .mdc-form-field {
- color: var(--md-sys-color-on-surface, #e2e2e5);
- }
-
- .mdc-radio__outer-circle {
- border-color: var(--md-sys-color-on-surface-variant, #c4c6cf);
- }
- }
- }
-
- [mat-raised-button] {
- background: var(--md-sys-color-primary, #a8c8ff);
- color: var(--md-sys-color-on-primary, #003258);
- }
-
- [mat-button] {
- color: var(--md-sys-color-primary, #a8c8ff);
-
- &:hover:not([disabled]) {
- background: rgb(168 200 255 / 12%);
- }
- }
- }
-}
-
-// ===== Responsive Styles =====
-/* stylelint-disable-next-line media-feature-range-notation -- Safari compatibility */
-@media (max-width: 768px) {
- .radio-group-spacing {
- gap: 0.5rem;
- }
-
- .two-factor-auth-form {
- .two-factor-auth-input,
- [mat-raised-button],
- [mat-button] {
- max-width: 100%;
- }
+ .tfa-otp__input {
+ font-size: 22px;
+ letter-spacing: 10px;
+ padding-left: 10px;
}
}
diff --git a/src/app/login/two-factor-authentication/two-factor-authentication.component.ts b/src/app/login/two-factor-authentication/two-factor-authentication.component.ts
index cd9cb0991..1e05cf143 100644
--- a/src/app/login/two-factor-authentication/two-factor-authentication.component.ts
+++ b/src/app/login/two-factor-authentication/two-factor-authentication.component.ts
@@ -15,10 +15,8 @@ import { finalize } from 'rxjs/operators';
/** Custom Services */
import { AuthenticationService } from '../../core/authentication/authentication.service';
-import { MatDivider } from '@angular/material/divider';
import { MatRadioGroup, MatRadioButton } from '@angular/material/radio';
import { MatProgressSpinner } from '@angular/material/progress-spinner';
-import { MatFormField, MatPrefix, MatLabel, MatHint, MatError } from '@angular/material/form-field';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
@@ -31,13 +29,10 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
styleUrls: ['./two-factor-authentication.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
- MatDivider,
MatRadioGroup,
MatRadioButton,
MatProgressSpinner,
- MatPrefix,
- FaIconComponent,
- MatHint
+ FaIconComponent
]
})
export class TwoFactorAuthenticationComponent implements OnInit {
@@ -69,7 +64,18 @@ export class TwoFactorAuthenticationComponent implements OnInit {
ngOnInit() {
this.createTwoFactorAuthenticationDeliveryMethodForm();
this.authenticationService.getDeliveryMethods().subscribe((deliveryMethods: any) => {
- this.twoFactorAuthenticationDeliveryMethods = deliveryMethods;
+ // Keep only methods the user has actually configured (target populated).
+ // Methods returned without a destination are noise from the backend.
+ const enabled = (deliveryMethods || []).filter((m: any) => !!m?.target);
+ this.twoFactorAuthenticationDeliveryMethods = enabled;
+
+ // If exactly one method is enabled, skip the picker and request OTP automatically.
+ if (enabled.length === 1) {
+ this.twoFactorAuthenticationDeliveryMethodForm
+ .get('twoFactorAuthenticationDeliveryMethod')
+ ?.setValue(enabled[0]);
+ this.requestOTP();
+ }
});
}
@@ -120,6 +126,35 @@ export class TwoFactorAuthenticationComponent implements OnInit {
.subscribe();
}
+ /** Explicitly select a delivery method when the row is clicked. */
+ selectDeliveryMethod(method: any): void {
+ this.twoFactorAuthenticationDeliveryMethodForm.get('twoFactorAuthenticationDeliveryMethod')?.setValue(method);
+ }
+
+ /**
+ * Mask the delivery target so only the last 4 characters are visible.
+ * Mobile → "+XXXXXX2076"; Email → "ad***@gmail.com".
+ */
+ maskTarget(method: any): string {
+ const target = method?.target || '';
+ if (!target) return '';
+ const channel = (method?.name || '').toString().toLowerCase();
+ const isMobile = channel === 'sms' || channel === 'mobile' || channel === 'phone';
+ if (isMobile) {
+ const digits = target.replace(/\D/g, '');
+ if (digits.length <= 4) return target;
+ return '+' + 'X'.repeat(digits.length - 4) + digits.slice(-4);
+ }
+ // Email — keep first 2 chars of local part, mask the rest, keep domain
+ const at = target.indexOf('@');
+ if (at <= 0) return target;
+ const local = target.substring(0, at);
+ const domain = target.substring(at);
+ const visible = local.substring(0, Math.min(2, local.length));
+ const hidden = 'X'.repeat(Math.max(0, local.length - visible.length));
+ return visible + hidden + domain;
+ }
+
/**
* Resends OTP via the selected delivery method.
*/
diff --git a/src/app/otp-audit-logs/otp-audit-logs-routing.module.ts b/src/app/otp-audit-logs/otp-audit-logs-routing.module.ts
new file mode 100644
index 000000000..b57e48837
--- /dev/null
+++ b/src/app/otp-audit-logs/otp-audit-logs-routing.module.ts
@@ -0,0 +1,37 @@
+/**
+ * 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 { NgModule } from '@angular/core';
+import { Routes, RouterModule } from '@angular/router';
+
+import { Route } from '../core/route/route.service';
+import { OtpAuditLogsComponent } from './otp-audit-logs.component';
+import { ViewOtpLogComponent } from './view-otp-log/view-otp-log.component';
+
+const routes: Routes = [
+ Route.withShell([
+ {
+ path: 'otp-audit-logs',
+ data: { title: 'OTP Audit Logs', breadcrumb: 'OTP Audit Logs' },
+ children: [
+ { path: '', component: OtpAuditLogsComponent },
+ {
+ path: ':id',
+ component: ViewOtpLogComponent,
+ data: { title: 'OTP Log Detail', breadcrumb: 'Detail' }
+ }
+ ]
+ }
+ ])
+];
+
+@NgModule({
+ imports: [RouterModule.forChild(routes)],
+ exports: [RouterModule]
+})
+export class OtpAuditLogsRoutingModule {}
diff --git a/src/app/otp-audit-logs/otp-audit-logs.component.html b/src/app/otp-audit-logs/otp-audit-logs.component.html
new file mode 100644
index 000000000..9765cb859
--- /dev/null
+++ b/src/app/otp-audit-logs/otp-audit-logs.component.html
@@ -0,0 +1,186 @@
+
+
+
+
+
+
+
+
+
+
+ @if (loading) {
+
+ }
+
+
+
+ ID
+ #{{ row.id }}
+
+
+
+ Created
+ {{ formatDate(row.createdOn) }}
+
+
+
+ User
+ {{ row.username || '—' }}
+
+
+
+ Channel
+
+
+ {{
+ row.channel === 'EMAIL' ? 'mail' : row.channel === 'TOTP' ? 'qr_code_2' : 'smartphone'
+ }}
+ {{ row.channel || '—' }}
+
+
+
+
+
+ Destination
+ {{ row.destination || '—' }}
+
+
+
+ Status
+
+ {{ row.status || '—' }}
+
+
+
+
+ Attempts
+
+ {{ row.attempts ?? 0 }}{{ row.maxAttempts ? '/' + row.maxAttempts : '' }}
+
+
+
+
+ Verified
+ {{ formatDate(row.verifiedOn) }}
+
+
+
+ Actions
+
+
+ visibility
+
+
+
+
+
+
+
+
+ @if (!loading && errorStatus) {
+
+
{{ errorStatus === 401 || errorStatus === 403 ? 'lock' : 'error_outline' }}
+
+
+ @if (errorStatus === 403) {
+ Access denied (HTTP 403)
+ } @else if (errorStatus === 401) {
+ Sign in required (HTTP 401)
+ } @else {
+ Failed to load (HTTP {{ errorStatus }})
+ }
+
+ {{ errorMsg }}
+ @if (errorStatus === 403) {
+
+ Your role doesn't have permission to read OTP logs. Ask an admin to add the
+ READ_OTP_LOG permission to your role under
+ Admin → System → Manage Roles and Permissions .
+
+ }
+
+
+ } @else if (!loading && dataSource.data.length === 0) {
+
+
inbox
+
No OTP logs match the current filters.
+
+ }
+
+
+
+
+
diff --git a/src/app/otp-audit-logs/otp-audit-logs.component.scss b/src/app/otp-audit-logs/otp-audit-logs.component.scss
new file mode 100644
index 000000000..1cbeb6f72
--- /dev/null
+++ b/src/app/otp-audit-logs/otp-audit-logs.component.scss
@@ -0,0 +1,299 @@
+/**
+ * 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/.
+ */
+
+.oal-container {
+ padding: 18px 22px 26px;
+ max-width: 1280px;
+ margin: 0 auto;
+}
+
+.oal-head {
+ display: flex;
+ align-items: center;
+ gap: 14px;
+ padding: 18px 22px;
+ border-radius: 14px;
+ background: linear-gradient(135deg, #eff6ff 0%, #fff 60%);
+ border: 1px solid #dbeafe;
+ margin-bottom: 18px;
+
+ &__icon {
+ width: 46px;
+ height: 46px;
+ border-radius: 12px;
+ background: #1e40af;
+ color: #fff;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 18px;
+ flex-shrink: 0;
+ }
+
+ &__text {
+ flex: 1;
+ min-width: 0;
+
+ h2 {
+ margin: 0 0 4px;
+ font-size: 17px;
+ font-weight: 700;
+ color: #0f172a;
+ }
+
+ p {
+ margin: 0;
+ font-size: 12.5px;
+ color: #64748b;
+ line-height: 1.5;
+ }
+ }
+
+ &__buttons {
+ display: flex;
+ gap: 8px;
+ }
+
+ &__btn mat-icon {
+ font-size: 18px;
+ width: 18px;
+ height: 18px;
+ margin-right: 6px;
+ vertical-align: middle;
+ }
+}
+
+.oal-filters {
+ display: grid;
+ grid-template-columns: 2fr 1fr 1fr 1fr 1fr auto;
+ align-items: start;
+ gap: 12px;
+ padding: 14px 16px;
+ background: #f8fafc;
+ border: 1px solid #e2e8f0;
+ border-radius: 12px;
+ margin-bottom: 16px;
+
+ @media (width <= 1100px) {
+ grid-template-columns: repeat(2, 1fr);
+ }
+
+ &__actions {
+ display: flex;
+ gap: 8px;
+ align-items: center;
+
+ button mat-icon {
+ font-size: 16px;
+ width: 16px;
+ height: 16px;
+ margin-right: 4px;
+ vertical-align: middle;
+ }
+ }
+}
+
+.oal-ff {
+ width: 100%;
+ margin: 0;
+
+ &--grow {
+ @media (width >= 1101px) {
+ grid-column: span 1;
+ }
+ }
+}
+
+.oal-card {
+ border: 1px solid #e2e8f0;
+ border-radius: 14px;
+ background: #fff;
+ overflow: hidden;
+}
+
+.oal-table-wrap {
+ overflow-x: auto;
+}
+
+.oal-table {
+ width: 100%;
+ min-width: 900px;
+
+ th {
+ background: #f8fafc;
+ color: #475569;
+ font-size: 11.5px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.4px;
+ }
+
+ td {
+ font-size: 13px;
+ color: #334155;
+ }
+
+ .oal-mono {
+ font-family: 'SF Mono', 'Cascadia Code', Consolas, monospace;
+ font-weight: 600;
+ color: #0f172a;
+ }
+
+ .oal-actions {
+ width: 56px;
+ text-align: right;
+ }
+}
+
+.oal-row {
+ cursor: pointer;
+ transition: background 0.15s;
+
+ &:hover {
+ background: #eff6ff;
+ }
+}
+
+.oal-chip {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 3px 10px;
+ border-radius: 999px;
+ background: #eef2ff;
+ color: #3730a3;
+ border: 1px solid #c7d2fe;
+ font-size: 11.5px;
+ font-weight: 700;
+
+ mat-icon {
+ font-size: 14px;
+ width: 14px;
+ height: 14px;
+ }
+
+ &[data-channel='EMAIL'] {
+ background: #cffafe;
+ color: #155e75;
+ border-color: #a5f3fc;
+ }
+
+ &[data-channel='TOTP'] {
+ background: #ede9fe;
+ color: #5b21b6;
+ border-color: #ddd6fe;
+ }
+}
+
+.oal-status {
+ display: inline-block;
+ padding: 3px 10px;
+ border-radius: 999px;
+ font-size: 11px;
+ font-weight: 700;
+ letter-spacing: 0.4px;
+ text-transform: uppercase;
+ background: #f1f5f9;
+ color: #475569;
+ border: 1px solid #e2e8f0;
+
+ &.is-ok {
+ background: #dcfce7;
+ color: #166534;
+ border-color: #bbf7d0;
+ }
+
+ &.is-warn {
+ background: #fef3c7;
+ color: #92400e;
+ border-color: #fde68a;
+ }
+
+ &.is-bad {
+ background: #fee2e2;
+ color: #b91c1c;
+ border-color: #fecaca;
+ }
+}
+
+.oal-dim {
+ color: #94a3b8;
+ font-weight: 400;
+ margin-left: 2px;
+}
+
+.oal-error {
+ display: flex;
+ align-items: flex-start;
+ gap: 12px;
+ margin: 16px;
+ padding: 14px 16px;
+ border: 1px solid #fecaca;
+ background: #fef2f2;
+ border-radius: 12px;
+ color: #b91c1c;
+ font-size: 13px;
+ line-height: 1.5;
+
+ > mat-icon {
+ font-size: 22px;
+ width: 22px;
+ height: 22px;
+ color: #dc2626;
+ flex-shrink: 0;
+ margin-top: 1px;
+ }
+
+ > div {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ min-width: 0;
+ }
+
+ strong {
+ color: #991b1b;
+ font-weight: 700;
+ }
+
+ &__hint {
+ color: #475569;
+ font-size: 12.5px;
+
+ code {
+ font-family: 'SF Mono', 'Cascadia Code', Consolas, monospace;
+ background: #fff;
+ padding: 1px 6px;
+ border-radius: 4px;
+ border: 1px solid #fecaca;
+ color: #991b1b;
+ font-weight: 700;
+ }
+
+ em {
+ font-style: italic;
+ color: #1e40af;
+ }
+ }
+}
+
+.oal-empty {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 6px;
+ padding: 40px 20px;
+ color: #94a3b8;
+ font-size: 13px;
+
+ mat-icon {
+ font-size: 36px;
+ width: 36px;
+ height: 36px;
+ }
+}
diff --git a/src/app/otp-audit-logs/otp-audit-logs.component.ts b/src/app/otp-audit-logs/otp-audit-logs.component.ts
new file mode 100644
index 000000000..8f8fb7a1d
--- /dev/null
+++ b/src/app/otp-audit-logs/otp-audit-logs.component.ts
@@ -0,0 +1,290 @@
+/**
+ * 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 { Component, OnInit, ViewChild, inject } from '@angular/core';
+import { FormsModule } from '@angular/forms';
+import { Router, RouterLink } from '@angular/router';
+import {
+ MatTableDataSource,
+ MatTable,
+ MatColumnDef,
+ MatHeaderCellDef,
+ MatHeaderCell,
+ MatCellDef,
+ MatCell,
+ MatHeaderRowDef,
+ MatHeaderRow,
+ MatRowDef,
+ MatRow
+} from '@angular/material/table';
+import { MatPaginator, PageEvent } from '@angular/material/paginator';
+import { MatSort } from '@angular/material/sort';
+import { MatProgressBar } from '@angular/material/progress-bar';
+import { MatButton, MatIconButton } from '@angular/material/button';
+import { MatIcon } from '@angular/material/icon';
+import { MatFormField, MatLabel, MatPrefix } from '@angular/material/form-field';
+import { MatInput } from '@angular/material/input';
+import { MatSelect } from '@angular/material/select';
+import { MatOption } from '@angular/material/core';
+import { MatDatepicker, MatDatepickerInput, MatDatepickerToggle } from '@angular/material/datepicker';
+import { MatNativeDateModule } from '@angular/material/core';
+import { FaIconComponent } from '@fortawesome/angular-fontawesome';
+import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
+import { OtpAuditLogsService, OtpLog, OtpLogPage } from './otp-audit-logs.service';
+
+@Component({
+ selector: 'mifosx-otp-audit-logs',
+ templateUrl: './otp-audit-logs.component.html',
+ styleUrls: ['./otp-audit-logs.component.scss'],
+ imports: [
+ ...STANDALONE_SHARED_IMPORTS,
+ FormsModule,
+ RouterLink,
+ MatTable,
+ MatColumnDef,
+ MatHeaderCellDef,
+ MatHeaderCell,
+ MatCellDef,
+ MatCell,
+ MatHeaderRowDef,
+ MatHeaderRow,
+ MatRowDef,
+ MatRow,
+ MatPaginator,
+ MatProgressBar,
+ MatButton,
+ MatIconButton,
+ MatIcon,
+ MatFormField,
+ MatLabel,
+ MatPrefix,
+ MatInput,
+ MatSelect,
+ MatOption,
+ MatDatepicker,
+ MatDatepickerInput,
+ MatDatepickerToggle,
+ MatNativeDateModule,
+ FaIconComponent
+ ]
+})
+export class OtpAuditLogsComponent implements OnInit {
+ private service = inject(OtpAuditLogsService);
+ private router = inject(Router);
+
+ @ViewChild(MatPaginator) paginator!: MatPaginator;
+
+ loading = false;
+ totalElements = 0;
+ dataSource = new MatTableDataSource([]);
+ /** Last error returned by the listing endpoint (if any) */
+ errorStatus = 0;
+ errorMsg = '';
+
+ displayedColumns: string[] = [
+ 'id',
+ 'createdOn',
+ 'username',
+ 'channel',
+ 'destination',
+ 'status',
+ 'attempts',
+ 'verifiedOn',
+ 'actions'
+ ];
+
+ /** Filter state */
+ search = '';
+ status = '';
+ channel = '';
+ dateFrom: Date | null = null;
+ dateTo: Date | null = null;
+ pageSize = 25;
+ pageIndex = 0;
+
+ readonly statusOptions = [
+ { value: '', label: 'All' },
+ { value: 'PENDING', label: 'Pending' },
+ { value: 'VERIFIED', label: 'Verified' },
+ { value: 'FAILED', label: 'Failed' },
+ { value: 'EXPIRED', label: 'Expired' },
+ { value: 'LOCKED', label: 'Locked' }
+ ];
+
+ readonly channelOptions = [
+ { value: '', label: 'All' },
+ { value: 'SMS', label: 'SMS' },
+ { value: 'EMAIL', label: 'Email' },
+ { value: 'TOTP', label: 'Authenticator' }
+ ];
+
+ ngOnInit(): void {
+ this.fetch();
+ }
+
+ fetch(): void {
+ this.loading = true;
+ this.errorStatus = 0;
+ this.errorMsg = '';
+ this.service
+ .list({
+ page: this.pageIndex,
+ pageSize: this.pageSize,
+ search: this.search?.trim() || undefined,
+ status: this.status || undefined,
+ channel: this.channel || undefined,
+ dateFrom: this.toIso(this.dateFrom),
+ dateTo: this.toIso(this.dateTo)
+ })
+ .subscribe({
+ next: (res: OtpLogPage | OtpLog[]) => {
+ this.loading = false;
+ if (Array.isArray(res)) {
+ this.totalElements = res.length;
+ this.dataSource.data = res;
+ } else {
+ this.totalElements = res?.totalElements ?? res?.pageItems?.length ?? 0;
+ this.dataSource.data = res?.pageItems || [];
+ }
+ },
+ error: (err) => {
+ this.loading = false;
+ this.dataSource.data = [];
+ this.totalElements = 0;
+ this.errorStatus = err?.status || 0;
+ this.errorMsg =
+ err?.error?.defaultUserMessage || err?.error?.message || err?.message || 'Failed to load OTP audit logs.';
+ }
+ });
+ }
+
+ applyFilters(): void {
+ this.pageIndex = 0;
+ if (this.paginator) this.paginator.firstPage();
+ this.fetch();
+ }
+
+ clearFilters(): void {
+ this.search = '';
+ this.status = '';
+ this.channel = '';
+ this.dateFrom = null;
+ this.dateTo = null;
+ this.applyFilters();
+ }
+
+ onPage(e: PageEvent): void {
+ this.pageIndex = e.pageIndex;
+ this.pageSize = e.pageSize;
+ this.fetch();
+ }
+
+ view(log: OtpLog): void {
+ if (log?.id != null)
+ this.router.navigate([
+ '/otp-audit-logs',
+ log.id
+ ]);
+ }
+
+ refresh(): void {
+ this.fetch();
+ }
+
+ /** Export the currently displayed page as a CSV file. */
+ downloadCsv(): void {
+ const rows = this.dataSource.data || [];
+ if (!rows.length) return;
+ const headers = [
+ 'ID',
+ 'Created',
+ 'User',
+ 'Channel',
+ 'Destination',
+ 'Status',
+ 'Purpose',
+ 'Attempts',
+ 'Verified On',
+ 'IP',
+ 'Reference'
+ ];
+ const escape = (v: any) => {
+ if (v == null) return '';
+ const s = String(v).replace(/"/g, '""');
+ return /[",\n]/.test(s) ? `"${s}"` : s;
+ };
+ const lines = [headers.join(',')];
+ for (const r of rows) {
+ lines.push(
+ [
+ r.id,
+ this.formatDate(r.createdOn),
+ r.username,
+ r.channel,
+ r.destination,
+ r.status,
+ r.purpose,
+ r.attempts,
+ this.formatDate(r.verifiedOn),
+ r.ipAddress,
+ r.otpReference
+ ]
+ .map(escape)
+ .join(',')
+ );
+ }
+ const blob = new Blob([lines.join('\n')], { type: 'text/csv;charset=utf-8;' });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = `otp-audit-logs-${new Date().toISOString().slice(0, 10)}.csv`;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(url);
+ }
+
+ /** Format date arrays/strings into a friendly local string */
+ formatDate(value: any): string {
+ if (!value) return '—';
+ if (Array.isArray(value) && value.length >= 3) {
+ const [
+ y,
+ m,
+ d,
+ h = 0,
+ mi = 0,
+ s = 0
+ ] = value;
+ const dt = new Date(y, m - 1, d, h, mi, s);
+ return dt.toLocaleString();
+ }
+ if (typeof value === 'string') {
+ const dt = new Date(value);
+ return isNaN(dt.getTime()) ? value : dt.toLocaleString();
+ }
+ return String(value);
+ }
+
+ statusClass(status?: string): string {
+ if (!status) return '';
+ const s = status.toUpperCase();
+ if (s === 'VERIFIED') return 'is-ok';
+ if (s === 'PENDING') return 'is-warn';
+ if (s === 'FAILED' || s === 'EXPIRED' || s === 'LOCKED') return 'is-bad';
+ return '';
+ }
+
+ private toIso(d: Date | null): string | undefined {
+ if (!d) return undefined;
+ const yyyy = d.getFullYear();
+ const mm = String(d.getMonth() + 1).padStart(2, '0');
+ const dd = String(d.getDate()).padStart(2, '0');
+ return `${yyyy}-${mm}-${dd}`;
+ }
+}
diff --git a/src/app/otp-audit-logs/otp-audit-logs.module.ts b/src/app/otp-audit-logs/otp-audit-logs.module.ts
new file mode 100644
index 000000000..8945f1046
--- /dev/null
+++ b/src/app/otp-audit-logs/otp-audit-logs.module.ts
@@ -0,0 +1,23 @@
+/**
+ * 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 { NgModule } from '@angular/core';
+import { SharedModule } from '../shared/shared.module';
+import { OtpAuditLogsRoutingModule } from './otp-audit-logs-routing.module';
+import { OtpAuditLogsComponent } from './otp-audit-logs.component';
+import { ViewOtpLogComponent } from './view-otp-log/view-otp-log.component';
+
+@NgModule({
+ imports: [
+ SharedModule,
+ OtpAuditLogsRoutingModule,
+ OtpAuditLogsComponent,
+ ViewOtpLogComponent
+ ]
+})
+export class OtpAuditLogsModule {}
diff --git a/src/app/otp-audit-logs/otp-audit-logs.service.ts b/src/app/otp-audit-logs/otp-audit-logs.service.ts
new file mode 100644
index 000000000..8b278ff19
--- /dev/null
+++ b/src/app/otp-audit-logs/otp-audit-logs.service.ts
@@ -0,0 +1,67 @@
+/**
+ * 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 { HttpClient, HttpParams } from '@angular/common/http';
+import { Observable } from 'rxjs';
+
+export interface OtpLog {
+ id: number;
+ username?: string;
+ channel?: string; // SMS | EMAIL | TOTP
+ destination?: string; // mobile or email (masked from API ideally)
+ otpReference?: string;
+ status?: string; // PENDING | VERIFIED | FAILED | EXPIRED | LOCKED
+ purpose?: string; // LOGIN | MFA_SETUP | TXN_APPROVAL etc.
+ attempts?: number;
+ maxAttempts?: number;
+ createdOn?: string | number[];
+ expiresOn?: string | number[];
+ verifiedOn?: string | number[];
+ ipAddress?: string;
+ userAgent?: string;
+ message?: string; // last status message from upstream gateway
+}
+
+export interface OtpLogPage {
+ totalElements: number;
+ pageItems: OtpLog[];
+}
+
+export interface OtpLogQuery {
+ page?: number;
+ pageSize?: number;
+ search?: string;
+ status?: string;
+ channel?: string;
+ dateFrom?: string; // ISO yyyy-MM-dd
+ dateTo?: string;
+}
+
+@Injectable({ providedIn: 'root' })
+export class OtpAuditLogsService {
+ private http = inject(HttpClient);
+
+ /** Paginated list — falls back to a plain array if backend isn't paged yet. */
+ list(query: OtpLogQuery = {}): Observable {
+ let params = new HttpParams();
+ if (query.page != null) params = params.set('page', String(query.page));
+ if (query.pageSize != null) params = params.set('pageSize', String(query.pageSize));
+ if (query.search) params = params.set('search', query.search);
+ if (query.status) params = params.set('status', query.status);
+ if (query.channel) params = params.set('channel', query.channel);
+ if (query.dateFrom) params = params.set('dateFrom', query.dateFrom);
+ if (query.dateTo) params = params.set('dateTo', query.dateTo);
+ return this.http.get('/otp-logs', { params });
+ }
+
+ /** Detail view */
+ get(id: number | string): Observable {
+ return this.http.get(`/otp-logs/${id}`);
+ }
+}
diff --git a/src/app/otp-audit-logs/view-otp-log/view-otp-log.component.html b/src/app/otp-audit-logs/view-otp-log/view-otp-log.component.html
new file mode 100644
index 000000000..1915609af
--- /dev/null
+++ b/src/app/otp-audit-logs/view-otp-log/view-otp-log.component.html
@@ -0,0 +1,92 @@
+
+
+
+
arrow_back All OTP logs
+
+ @if (loading) {
+
+ }
+
+ @if (!loading && error) {
+
error_outline {{ error }}
+ }
+
+ @if (!loading && log) {
+
+
+
+ {{
+ log.channel === 'EMAIL' ? 'mail' : log.channel === 'TOTP' ? 'qr_code_2' : 'smartphone'
+ }}
+
+
+
+ OTP Log #{{ log.id }}
+
+
+ {{ log.channel || '—' }}
+ {{ log.status || '—' }}
+ @if (log.purpose) {
+ {{ log.purpose }}
+ }
+
+
+
+
+
+
+ User
+ {{ log.username || '—' }}
+
+
+ Destination
+ {{ log.destination || '—' }}
+
+
+ Reference
+ {{ log.otpReference || '—' }}
+
+
+ Attempts
+
+ {{ log.attempts ?? 0 }}{{ log.maxAttempts ? '/' + log.maxAttempts : '' }}
+
+
+
+ Created on
+ {{ formatDate(log.createdOn) }}
+
+
+ Expires on
+ {{ formatDate(log.expiresOn) }}
+
+
+ Verified on
+ {{ formatDate(log.verifiedOn) }}
+
+
+ IP address
+ {{ log.ipAddress || '—' }}
+
+ @if (log.userAgent) {
+
+ User agent
+ {{ log.userAgent }}
+
+ }
+ @if (log.message) {
+
+ Gateway message
+ {{ log.message }}
+
+ }
+
+
+ }
+
diff --git a/src/app/otp-audit-logs/view-otp-log/view-otp-log.component.scss b/src/app/otp-audit-logs/view-otp-log/view-otp-log.component.scss
new file mode 100644
index 000000000..5cd5b7857
--- /dev/null
+++ b/src/app/otp-audit-logs/view-otp-log/view-otp-log.component.scss
@@ -0,0 +1,195 @@
+/**
+ * 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/.
+ */
+
+.vol-container {
+ padding: 18px 22px;
+ max-width: 980px;
+ margin: 0 auto;
+}
+
+.vol-back {
+ margin-bottom: 14px !important;
+
+ mat-icon {
+ font-size: 18px;
+ width: 18px;
+ height: 18px;
+ margin-right: 6px;
+ vertical-align: middle;
+ }
+}
+
+.vol-error {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 12px 16px;
+ border-radius: 12px;
+ background: #fef2f2;
+ border: 1px solid #fecaca;
+ color: #b91c1c;
+ font-size: 13px;
+ margin-top: 10px;
+
+ mat-icon {
+ color: #dc2626;
+ }
+}
+
+.vol-card {
+ border: 1px solid #e2e8f0;
+ border-radius: 14px;
+ background: #fff;
+ padding: 18px 20px 22px;
+}
+
+.vol-head {
+ display: flex;
+ align-items: center;
+ gap: 14px;
+ padding-bottom: 14px;
+ margin-bottom: 16px;
+ border-bottom: 1px solid #f1f5f9;
+
+ &__icon {
+ width: 50px;
+ height: 50px;
+ border-radius: 14px;
+ background: #1e40af;
+ color: #fff;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+
+ mat-icon {
+ font-size: 24px;
+ width: 24px;
+ height: 24px;
+ }
+ }
+
+ &__title h2 {
+ margin: 0 0 4px;
+ font-size: 17px;
+ font-weight: 700;
+ color: #0f172a;
+ }
+}
+
+.vol-mono {
+ font-family: 'SF Mono', 'Cascadia Code', Consolas, monospace;
+ color: #0f172a;
+ font-weight: 700;
+}
+
+.vol-meta {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.vol-chip {
+ display: inline-block;
+ padding: 2px 10px;
+ border-radius: 999px;
+ background: #eef2ff;
+ color: #3730a3;
+ border: 1px solid #c7d2fe;
+ font-size: 11px;
+ font-weight: 700;
+ letter-spacing: 0.4px;
+ text-transform: uppercase;
+
+ &--purpose {
+ background: #ecfeff;
+ color: #155e75;
+ border-color: #a5f3fc;
+ }
+}
+
+.vol-status {
+ display: inline-block;
+ padding: 2px 10px;
+ border-radius: 999px;
+ font-size: 11px;
+ font-weight: 700;
+ letter-spacing: 0.4px;
+ text-transform: uppercase;
+ background: #f1f5f9;
+ color: #475569;
+ border: 1px solid #e2e8f0;
+
+ &.is-ok {
+ background: #dcfce7;
+ color: #166534;
+ border-color: #bbf7d0;
+ }
+
+ &.is-warn {
+ background: #fef3c7;
+ color: #92400e;
+ border-color: #fde68a;
+ }
+
+ &.is-bad {
+ background: #fee2e2;
+ color: #b91c1c;
+ border-color: #fecaca;
+ }
+}
+
+.vol-grid {
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ gap: 14px 18px;
+
+ @media (width <= 640px) {
+ grid-template-columns: 1fr;
+ }
+}
+
+.vol-cell {
+ display: flex;
+ flex-direction: column;
+ gap: 3px;
+
+ &--full {
+ grid-column: 1 / -1;
+ }
+
+ &__label {
+ font-size: 10.5px;
+ font-weight: 700;
+ color: #64748b;
+ letter-spacing: 0.4px;
+ text-transform: uppercase;
+ }
+
+ &__value {
+ font-size: 13.5px;
+ color: #0f172a;
+ word-break: break-word;
+
+ &--small {
+ font-size: 12px;
+ color: #475569;
+ line-height: 1.5;
+ font-family: 'SF Mono', 'Cascadia Code', Consolas, monospace;
+ background: #f8fafc;
+ border: 1px solid #e2e8f0;
+ border-radius: 8px;
+ padding: 8px 10px;
+ }
+ }
+}
+
+.vol-dim {
+ color: #94a3b8;
+ font-weight: 400;
+ margin-left: 2px;
+}
diff --git a/src/app/otp-audit-logs/view-otp-log/view-otp-log.component.ts b/src/app/otp-audit-logs/view-otp-log/view-otp-log.component.ts
new file mode 100644
index 000000000..16e777486
--- /dev/null
+++ b/src/app/otp-audit-logs/view-otp-log/view-otp-log.component.ts
@@ -0,0 +1,90 @@
+/**
+ * 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 { Component, OnInit, inject } from '@angular/core';
+import { ActivatedRoute, Router } from '@angular/router';
+import { MatProgressBar } from '@angular/material/progress-bar';
+import { MatButton, MatIconButton } from '@angular/material/button';
+import { MatIcon } from '@angular/material/icon';
+import { FaIconComponent } from '@fortawesome/angular-fontawesome';
+import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
+import { OtpAuditLogsService, OtpLog } from '../otp-audit-logs.service';
+
+@Component({
+ selector: 'mifosx-view-otp-log',
+ templateUrl: './view-otp-log.component.html',
+ styleUrls: ['./view-otp-log.component.scss'],
+ imports: [
+ ...STANDALONE_SHARED_IMPORTS,
+ MatProgressBar,
+ MatButton,
+ MatIconButton,
+ MatIcon,
+ FaIconComponent
+ ]
+})
+export class ViewOtpLogComponent implements OnInit {
+ private route = inject(ActivatedRoute);
+ private router = inject(Router);
+ private service = inject(OtpAuditLogsService);
+
+ loading = true;
+ log: OtpLog | null = null;
+ error = '';
+
+ ngOnInit(): void {
+ const id = this.route.snapshot.paramMap.get('id');
+ if (!id) {
+ this.error = 'Invalid log id';
+ this.loading = false;
+ return;
+ }
+ this.service.get(id).subscribe({
+ next: (res: OtpLog) => {
+ this.loading = false;
+ this.log = res;
+ },
+ error: (err) => {
+ this.loading = false;
+ this.error = err?.error?.message || err?.error?.defaultUserMessage || 'Could not load log';
+ }
+ });
+ }
+
+ back(): void {
+ this.router.navigate(['/otp-audit-logs']);
+ }
+
+ formatDate(value: any): string {
+ if (!value) return '—';
+ if (Array.isArray(value) && value.length >= 3) {
+ const [
+ y,
+ m,
+ d,
+ h = 0,
+ mi = 0,
+ s = 0
+ ] = value;
+ return new Date(y, m - 1, d, h, mi, s).toLocaleString();
+ }
+ if (typeof value === 'string') {
+ const dt = new Date(value);
+ return isNaN(dt.getTime()) ? value : dt.toLocaleString();
+ }
+ return String(value);
+ }
+
+ statusClass(status?: string): string {
+ if (!status) return '';
+ const s = status.toUpperCase();
+ if (s === 'VERIFIED') return 'is-ok';
+ if (s === 'PENDING') return 'is-warn';
+ return 'is-bad';
+ }
+}
diff --git a/src/app/shared/styles/list-layout.scss b/src/app/shared/styles/list-layout.scss
index 086ec3c0c..8ddbe01e0 100644
--- a/src/app/shared/styles/list-layout.scss
+++ b/src/app/shared/styles/list-layout.scss
@@ -456,6 +456,11 @@ $mono: 'Roboto Mono', 'DM Mono', monospace;
color: #475569;
}
+.cell-empty {
+ color: #94a3b8;
+ font-style: italic;
+}
+
// ===== Status Badge =====
.status-badge {
display: inline-flex;
diff --git a/src/app/users/create-user/create-user.component.html b/src/app/users/create-user/create-user.component.html
index bfa0a7d55..096865728 100644
--- a/src/app/users/create-user/create-user.component.html
+++ b/src/app/users/create-user/create-user.component.html
@@ -27,7 +27,8 @@
{{ 'labels.inputs.Username' | translate }}
-
+ person
+
@if (userForm.controls.username.hasError('required')) {
{{ 'labels.inputs.Username' | translate }} {{ 'labels.commons.is' | translate }}
@@ -36,26 +37,10 @@
}
-
- {{ 'labels.inputs.Email' | translate }}
-
- @if (userForm.controls.email.hasError('email')) {
- {{ 'labels.inputs.Email' | translate }} {{ 'labels.commons.is' | translate }}
- {{ 'labels.commons.invalid' | translate }}
- }
- @if (userForm.controls.email.hasError('required')) {
- {{ 'labels.inputs.Email' | translate }} {{ 'labels.commons.is' | translate }}
- {{ 'labels.commons.required' | translate }}
- }
-
-
{{ 'labels.inputs.First Name' | translate }}
-
+ badge
+
@if (userForm.controls.firstname.hasError('pattern')) {
{{ 'labels.inputs.First Name' | translate }}
@@ -74,7 +59,8 @@
{{ 'labels.inputs.Last Name' | translate }}
-
+ person_outline
+
@if (userForm.controls.lastname.hasError('pattern')) {
{{ 'labels.inputs.Last Name' | translate }}
@@ -90,6 +76,54 @@
>
}
+
+
+ {{ 'labels.inputs.Email' | translate }}
+ email
+
+ @if (userForm.controls.email.hasError('email')) {
+ {{ 'labels.inputs.Email' | translate }} {{ 'labels.commons.is' | translate }}
+ {{ 'labels.commons.invalid' | translate }}
+ }
+ @if (userForm.controls.email.hasError('required')) {
+ {{ 'labels.inputs.Email' | translate }} {{ 'labels.commons.is' | translate }}
+ {{ 'labels.commons.required' | translate }}
+ }
+
+
+
+ Mobile Number
+
+ phone_android
+ +91
+
+ = 48 && $event.charCode <= 57 ? true : $event.preventDefault()"
+ />
+ @if (userForm.controls.mobileNo.hasError('required')) {
+ Mobile number {{ 'labels.commons.is' | translate }}
+ {{ 'labels.commons.required' | translate }}
+
+ }
+ @if (userForm.controls.mobileNo.hasError('pattern')) {
+ Enter a valid 10-digit mobile number
+ }
+
{{ 'labels.inputs.Roles' | translate }}
diff --git a/src/environments/.env.ts b/src/environments/.env.ts
index 2a3c8fdfb..77fbcad14 100644
--- a/src/environments/.env.ts
+++ b/src/environments/.env.ts
@@ -9,8 +9,8 @@
/* tslint:disable */
export default {
'mifos_x': {
- 'version': '260429',
- 'hash': '0d3d8379'
+ 'version': '260509',
+ 'hash': 'g2d4fb4237'
},
'allow_switching_backend_instance': true
};
diff --git a/src/main.scss b/src/main.scss
index 96bbc55f4..a490be64e 100644
--- a/src/main.scss
+++ b/src/main.scss
@@ -1041,3 +1041,29 @@ mat-card {
margin: 0 !important;
}
}
+
+/* ===================== Two-factor authentication dialog ===================== */
+.tfa-dialog-panel {
+ .mat-mdc-dialog-surface {
+ background: linear-gradient(160deg, #0b1220 0%, #0f172a 60%, #111c33 100%) !important;
+ border-radius: 18px !important;
+ padding: 0 !important;
+ box-shadow: 0 30px 80px rgb(0 0 0 / 55%) !important;
+ overflow: hidden;
+ }
+
+ .mat-mdc-dialog-container {
+ --mdc-dialog-container-color: transparent;
+
+ padding: 0 !important;
+ }
+}
+
+.cdk-overlay-pane.tfa-dialog-panel {
+ border-radius: 18px;
+}
+
+.tfa-dialog-backdrop {
+ background: rgb(2 6 23 / 75%) !important;
+ backdrop-filter: blur(4px);
+}