Files
cbsnexora/src/app/otp-audit-logs/otp-audit-logs.component.ts
T
Aditya 3b752dc6ae 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
2026-05-10 13:24:48 +05:30

291 lines
7.6 KiB
TypeScript

/**
* 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}`;
}
}