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