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,92 @@
|
||||
<!--
|
||||
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/.
|
||||
-->
|
||||
|
||||
<div class="vol-container">
|
||||
<button mat-stroked-button class="vol-back" (click)="back()"><mat-icon>arrow_back</mat-icon> All OTP logs</button>
|
||||
|
||||
@if (loading) {
|
||||
<mat-progress-bar mode="indeterminate"></mat-progress-bar>
|
||||
}
|
||||
|
||||
@if (!loading && error) {
|
||||
<div class="vol-error"><mat-icon>error_outline</mat-icon> {{ error }}</div>
|
||||
}
|
||||
|
||||
@if (!loading && log) {
|
||||
<div class="vol-card">
|
||||
<header class="vol-head">
|
||||
<div class="vol-head__icon">
|
||||
<mat-icon>{{
|
||||
log.channel === 'EMAIL' ? 'mail' : log.channel === 'TOTP' ? 'qr_code_2' : 'smartphone'
|
||||
}}</mat-icon>
|
||||
</div>
|
||||
<div class="vol-head__title">
|
||||
<h2>
|
||||
OTP Log <span class="vol-mono">#{{ log.id }}</span>
|
||||
</h2>
|
||||
<span class="vol-meta">
|
||||
<span class="vol-chip">{{ log.channel || '—' }}</span>
|
||||
<span class="vol-status" [ngClass]="statusClass(log.status)">{{ log.status || '—' }}</span>
|
||||
@if (log.purpose) {
|
||||
<span class="vol-chip vol-chip--purpose">{{ log.purpose }}</span>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="vol-grid">
|
||||
<div class="vol-cell">
|
||||
<span class="vol-cell__label">User</span>
|
||||
<span class="vol-cell__value">{{ log.username || '—' }}</span>
|
||||
</div>
|
||||
<div class="vol-cell">
|
||||
<span class="vol-cell__label">Destination</span>
|
||||
<span class="vol-cell__value vol-mono">{{ log.destination || '—' }}</span>
|
||||
</div>
|
||||
<div class="vol-cell">
|
||||
<span class="vol-cell__label">Reference</span>
|
||||
<span class="vol-cell__value vol-mono">{{ log.otpReference || '—' }}</span>
|
||||
</div>
|
||||
<div class="vol-cell">
|
||||
<span class="vol-cell__label">Attempts</span>
|
||||
<span class="vol-cell__value">
|
||||
{{ log.attempts ?? 0 }}<span class="vol-dim">{{ log.maxAttempts ? '/' + log.maxAttempts : '' }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="vol-cell">
|
||||
<span class="vol-cell__label">Created on</span>
|
||||
<span class="vol-cell__value">{{ formatDate(log.createdOn) }}</span>
|
||||
</div>
|
||||
<div class="vol-cell">
|
||||
<span class="vol-cell__label">Expires on</span>
|
||||
<span class="vol-cell__value">{{ formatDate(log.expiresOn) }}</span>
|
||||
</div>
|
||||
<div class="vol-cell">
|
||||
<span class="vol-cell__label">Verified on</span>
|
||||
<span class="vol-cell__value">{{ formatDate(log.verifiedOn) }}</span>
|
||||
</div>
|
||||
<div class="vol-cell">
|
||||
<span class="vol-cell__label">IP address</span>
|
||||
<span class="vol-cell__value vol-mono">{{ log.ipAddress || '—' }}</span>
|
||||
</div>
|
||||
@if (log.userAgent) {
|
||||
<div class="vol-cell vol-cell--full">
|
||||
<span class="vol-cell__label">User agent</span>
|
||||
<span class="vol-cell__value vol-cell__value--small">{{ log.userAgent }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (log.message) {
|
||||
<div class="vol-cell vol-cell--full">
|
||||
<span class="vol-cell__label">Gateway message</span>
|
||||
<span class="vol-cell__value vol-cell__value--small">{{ log.message }}</span>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user