feat: MFA/2FA, OTP audit logs, KYC + client/user enhancements
- Access Control module: SMS/Email/TOTP/Hardware-token MFA factor management - OTP Audit Logs module under Admin menu (list + detail) with 401/403 handling - Two-factor authentication login flow updates - Client KYC, India address, family-members, ckyc, nominee enhancements - New shared KYC helpers (kyc-proof-types, kyc-upload-helper.service) - User create/edit/view enhancements - Shell navigation: OTP Audit Logs entry under real Admin menu
This commit is contained in:
@@ -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<OtpLog>([]);
|
||||
/** 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}`;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user