feat: letter-mgmt module + digital UPI/IMPS/ATM-POS reports + header redesign

letter-mgmt (new module):
- Notice page: overdue loan list with filters, KPI strip, bulk actions,
  call disposition logging (Promise-to-Pay, Not Reachable, etc.), Speed
  Post dispatch dialog with consignment + post-office tracking,
  PDF preview + download, call-contact dialog with primary / alt /
  family / nominee numbers and per-contact disposition history.
- Certificates: Loan Interest, Share, Balance, Form 60 (no-PAN flow),
  Form 15G/H (auto age-based), Hypothecation, NOC - each with shared
  preview dialog (six tone variants) and jsPDF generator using a
  common letterhead / summary-grid / signature-seal helper.

digital:
- IMPS module: report (P2A/P2P/Aadhaar tabs) + entry form with
  MMID/mobile lookup, NPCI switch resolve-to-A/C+IFSC BANL flow,
  sender autocomplete, hide sender field for P2P/Aadhaar, charges +
  18% GST + waive checkbox, per-sender today-only history dialog.
- UPI report with app-mix tab and full transaction filtering.
- ATM/POS/ECOM hub with three ticket cards routing to today/status/cycle.
- NEFT/RTGS entry: sender-account autocomplete, charges + GST + waive,
  field reorder (sender before amount), NEFT cap / RTGS floor
  validation, History button opening per-sender today's transactions.
- NEFT/RTGS report: horizontal-bar trend leaderboard replacing tall bars.

shell + theme:
- Header redesigned with IBM Plex Sans 12/500 typography, 11-column grid
  per row, top-accent hover indicator, bordered cells via grid-gap.
- Live KPI ticker below menu (CASH, TXN TODAY, CLEARING IN/OUT, NPA,
  CASA, CD RATIO, RBI CIRC, REPO) with marquee scroll, pause-on-hover,
  brand-blue gradient, amber bottom strip, pulsing red LIVE chip.
- Sidebar 'KYC Management' -> 'KYC & ReKYC Management'.
- Letter Mgmt / certificate menu items wired with green icon hints for
  items that have a working page.

access-control:
- MFA card layout compacted; method header inlines back button + title.

icons:
- Register faTruck/TruckFast, faEllipsisV/H, faCommentDots.
- Add IBM Plex Sans Google Fonts import to index.html.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Aditya
2026-05-19 10:09:06 +05:30
co-authored by Claude Opus 4.7
parent 4322f37c6d
commit 325228c514
138 changed files with 27473 additions and 3997 deletions
@@ -6,23 +6,25 @@
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="ac-container">
<!-- Page header -->
<div class="ac-page-head">
<div class="ac-page-head__icon"><fa-icon icon="shield-alt"></fa-icon></div>
<div class="ac-page-head__text">
<h2>Access Control · Multi-Factor Authentication</h2>
<p>
Pick a method to set up. The factor stays disabled until verification or registration completes for the
signed-in user <strong>{{ username }}</strong
>.
</p>
<div class="ac-container" [class.is-in-setup]="selectedMethod">
<!-- Page header (hidden once a method is being set up) -->
@if (!selectedMethod) {
<div class="ac-page-head">
<div class="ac-page-head__icon"><fa-icon icon="shield-alt"></fa-icon></div>
<div class="ac-page-head__text">
<h2>Access Control · Multi-Factor Authentication</h2>
<p>
Pick a method to set up. The factor stays disabled until verification or registration completes for the
signed-in user <strong>{{ username }}</strong
>.
</p>
</div>
<div class="ac-page-head__status" [class.is-on]="anyMethodActive">
<mat-icon>{{ anyMethodActive ? 'lock' : 'lock_open' }}</mat-icon>
{{ anyMethodActive ? 'MFA Active' : 'MFA Off' }}
</div>
</div>
<div class="ac-page-head__status" [class.is-on]="anyMethodActive">
<mat-icon>{{ anyMethodActive ? 'lock' : 'lock_open' }}</mat-icon>
{{ anyMethodActive ? 'MFA Active' : 'MFA Off' }}
</div>
</div>
}
<!-- ════════════ METHOD PICKER (default view) ════════════ -->
@if (!selectedMethod) {
@@ -918,4 +920,22 @@
}
</section>
}
<!-- Floating "Logs" chip — bottom-left of the viewport -->
<button
type="button"
class="ac-logs-chip"
(click)="openLogsDialog()"
matTooltip="View MFA activity & audit logs"
matTooltipPosition="right"
aria-label="View MFA activity logs"
>
<span class="ac-logs-chip__ic">
<fa-icon icon="history"></fa-icon>
</span>
<span class="ac-logs-chip__label">
<span class="ac-logs-chip__title">Logs</span>
<span class="ac-logs-chip__sub">Method &amp; sign-in activity</span>
</span>
</button>
</div>
@@ -398,9 +398,9 @@
.ac-back {
display: inline-flex;
align-items: center;
gap: 12px;
padding: 6px 16px 6px 6px;
margin-bottom: 16px;
gap: 10px;
padding: 4px 14px 4px 4px;
margin-bottom: 0;
border: 1px solid #e2e8f0;
border-radius: 999px;
background: #fff;
@@ -433,8 +433,8 @@
}
&__icon {
width: 32px;
height: 32px;
width: 28px;
height: 28px;
border-radius: 999px;
background: #eff6ff;
color: #1e40af;
@@ -447,9 +447,9 @@
color 0.18s;
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
font-size: 16px;
width: 16px;
height: 16px;
transition: transform 0.18s;
}
}
@@ -457,12 +457,12 @@
&__text {
display: flex;
flex-direction: column;
line-height: 1.15;
line-height: 1.1;
text-align: left;
}
&__sub {
font-size: 10px;
font-size: 9px;
font-weight: 700;
letter-spacing: 0.5px;
text-transform: uppercase;
@@ -470,13 +470,13 @@
}
&__main {
font-size: 13px;
font-size: 12px;
font-weight: 700;
color: #0f172a;
}
&--inner {
margin-bottom: 14px;
margin-bottom: 10px;
background: #f8fafc;
.ac-back__icon {
@@ -545,71 +545,99 @@
/* ───────────────────────── Setup panel ───────────────────────── */
.ac-panel {
margin-top: 14px;
margin-top: 10px;
border: 1px solid #e2e8f0;
border-radius: 16px;
background: #fff;
padding: 16px 20px;
padding: 12px 18px 16px;
scroll-margin-top: 80px;
/* Place the outer back button and the per-method header on the SAME row,
so the form below appears immediately (no extra scroll). */
display: grid;
grid-template-columns: auto 1fr;
gap: 0 14px;
align-items: center;
// stylelint-disable-next-line selector-max-universal
> * {
grid-column: 1 / -1;
min-width: 0;
}
> .ac-back {
grid-column: 1;
grid-row: 1;
align-self: center;
}
> .ac-panel__head {
grid-column: 2;
grid-row: 1;
}
&__head {
display: flex;
align-items: center;
gap: 14px;
padding-bottom: 12px;
border-bottom: 1px solid #f1f5f9;
margin-bottom: 16px;
gap: 12px;
padding-bottom: 0;
border-bottom: 0;
margin: 0 0 6px;
}
&__icon {
width: 50px;
height: 50px;
border-radius: 14px;
width: 38px;
height: 38px;
border-radius: 11px;
display: inline-flex;
align-items: center;
justify-content: center;
mat-icon {
font-size: 24px;
width: 24px;
height: 24px;
font-size: 20px;
width: 20px;
height: 20px;
}
}
&__title {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
h3 {
margin: 0 0 3px;
font-size: 16px;
margin: 0;
font-size: 15px;
font-weight: 700;
color: #0f172a;
}
}
&__sub {
margin: 0 0 16px;
font-size: 12.5px;
margin: 0 0 10px;
font-size: 12px;
color: #64748b;
line-height: 1.5;
line-height: 1.45;
}
&__body {
display: flex;
flex-direction: column;
gap: 14px;
gap: 12px;
}
}
/* ───────────────────────── Progress stepper ───────────────────────── */
.ac-stepper {
list-style: none;
margin: 0 0 14px;
padding: 10px 16px;
margin: 0 0 10px;
padding: 8px 14px;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
gap: 6px;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 12px;
@@ -618,8 +646,8 @@
position: relative;
display: flex;
align-items: center;
gap: 10px;
padding: 6px 4px;
gap: 8px;
padding: 4px 2px;
&::after {
content: '';
@@ -638,15 +666,15 @@
}
&__num {
width: 28px;
height: 28px;
width: 24px;
height: 24px;
border-radius: 999px;
background: #e2e8f0;
color: #94a3b8;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-size: 11px;
font-weight: 700;
flex-shrink: 0;
transition:
@@ -1628,4 +1656,142 @@
.ac-hw-row__cta {
grid-column: 1 / -1;
}
/* Stack back button above the per-method header on narrow screens. */
.ac-panel {
grid-template-columns: 1fr;
row-gap: 8px;
}
.ac-panel > .ac-back,
.ac-panel > .ac-panel__head {
grid-column: 1 / -1;
}
.ac-panel > .ac-back {
grid-row: 1;
justify-self: start;
}
.ac-panel > .ac-panel__head {
grid-row: 2;
}
}
/* ═══════════════════════════════════════════════════
Floating "Logs" chip — bottom-left, sticky to viewport
═══════════════════════════════════════════════════ */
.ac-logs-chip {
position: fixed;
left: 22px;
bottom: 22px;
display: inline-flex;
align-items: center;
gap: 11px;
padding: 10px 16px 10px 12px;
background: linear-gradient(135deg, #0b1e3d 0%, #1d4ed8 100%);
color: #fff;
border: 0;
border-radius: 999px;
box-shadow:
0 12px 26px rgb(11 30 61 / 35%),
0 4px 10px rgb(11 30 61 / 22%),
inset 0 1px 0 rgb(255 255 255 / 12%);
cursor: pointer;
font-family: inherit;
z-index: 60;
transition:
transform 0.18s ease,
box-shadow 0.18s ease;
&:hover {
transform: translateY(-2px);
box-shadow:
0 16px 32px rgb(11 30 61 / 42%),
0 6px 14px rgb(11 30 61 / 28%),
inset 0 1px 0 rgb(255 255 255 / 18%);
}
&:active {
transform: translateY(0);
}
&:focus-visible {
outline: 2px solid #60a5fa;
outline-offset: 3px;
}
}
.ac-logs-chip__ic {
width: 32px;
height: 32px;
border-radius: 50%;
background: rgb(255 255 255 / 14%);
border: 1px solid rgb(255 255 255 / 22%);
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 13px;
flex-shrink: 0;
}
.ac-logs-chip__label {
display: flex;
flex-direction: column;
line-height: 1.15;
text-align: left;
}
.ac-logs-chip__title {
font-size: 13px;
font-weight: 700;
letter-spacing: 0.04em;
}
.ac-logs-chip__sub {
font-size: 10px;
font-weight: 500;
color: rgb(255 255 255 / 70%);
margin-top: 2px;
text-transform: uppercase;
letter-spacing: 0.06em;
}
/* Narrow screens — collapse to an icon-only round button */
@media (width <= 600px) {
.ac-logs-chip {
padding: 10px;
border-radius: 50%;
}
.ac-logs-chip__label {
display: none;
}
.ac-logs-chip__ic {
width: 28px;
height: 28px;
}
}
/* ───── Dialog panel override — kill default Material padding ───── */
:host ::ng-deep .ml-dialog-panel {
.mat-mdc-dialog-surface {
padding: 0 !important;
border-radius: 14px !important;
overflow: hidden !important;
box-shadow:
0 24px 48px rgb(15 23 42 / 28%),
0 8px 16px rgb(15 23 42 / 18%) !important;
}
.mat-mdc-dialog-container .mdc-dialog__surface {
padding: 0 !important;
}
.mat-mdc-dialog-content {
padding: 0 !important;
margin: 0 !important;
max-height: none !important;
}
}
@@ -26,6 +26,7 @@ import { EnrollDscDialogComponent } from 'app/users/dsc/enroll-dsc-dialog/enroll
import { RevokeDscDialogComponent } from 'app/users/dsc/revoke-dsc-dialog/revoke-dsc-dialog.component';
import { SuccessDialogComponent } from 'app/shared/success-dialog/success-dialog.component';
import { ErrorDialogComponent } from 'app/shared/error-dialog/error-dialog.component';
import { MfaLogsDialogComponent } from './mfa-logs-dialog/mfa-logs-dialog.component';
type HwSubKind = 'smart-card' | 'digital-signature' | 'biometric';
type MethodKey = 'sms' | 'email' | 'totp' | 'hardware';
@@ -938,6 +939,17 @@ export class AccessControlComponent implements OnInit, OnDestroy {
this.dialog.open(ErrorDialogComponent, { data: message });
}
/** Floating Logs chip → open the tabbed audit-logs dialog. */
openLogsDialog(): void {
this.dialog.open(MfaLogsDialogComponent, {
width: '880px',
maxWidth: '95vw',
maxHeight: '90vh',
autoFocus: false,
panelClass: 'ml-dialog-panel'
});
}
/**
* Reconcile the DSC tile with the server: if /v1/dsc/me returns at least one ACTIVE
* DSC, the tile is considered "registered". Called from ngOnInit alongside the
@@ -7,7 +7,7 @@
*/
import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpHeaders } from '@angular/common/http';
import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
export interface OtpGenerateResponse {
@@ -46,6 +46,57 @@ export interface TotpVerifyResponse {
message?: string;
}
/** Which logical view the audit row belongs to. */
export type MfaLogTab = 'enrollment' | 'login';
/** Factor types reported by the audit feed (matches MfaFactorType minus TOKEN). */
export type MfaLogFactorType = 'SMS' | 'EMAIL' | 'TOTP' | 'DSC';
/** Status values — see API doc. Enrolment tab: ACTIVE/DISABLED; Login tab: the rest. */
export type MfaLogStatus = 'ACTIVE' | 'DISABLED' | 'PENDING' | 'SENT' | 'VERIFIED' | 'FAILED' | 'EXPIRED' | 'LOCKED';
/** Unified audit-log row returned by GET /v1/mfa/logs. */
export interface MfaLogEntry {
tab: 'ENROLLMENT' | 'LOGIN';
id: number;
source: 'USER_MFA_FACTOR' | 'OTP_LOG' | 'DSC_CHALLENGE';
userId: number;
username: string;
userFullName: string;
factorType: MfaLogFactorType;
destination: string;
status: MfaLogStatus;
/** Local datetime — when the event happened. */
eventOn: string;
/** UTC offset datetime — when the verification succeeded (null if not verified). */
verifiedOn?: string | null;
/** Login tab only. */
attempts?: number | null;
ipAddress?: string | null;
userAgent?: string | null;
message: string;
provider: string;
}
/** Paged response wrapper. */
export interface MfaLogPage {
totalFilteredRecords: number;
pageItems: MfaLogEntry[];
}
/** Optional filters — same shape for both tabs. */
export interface MfaLogFilters {
factorType?: MfaLogFactorType;
status?: MfaLogStatus;
userId?: number;
username?: string;
search?: string;
/** yyyy-MM-dd, treated as start-of-day. */
dateFrom?: string;
/** yyyy-MM-dd, treated as end-of-day (inclusive). */
dateTo?: string;
}
export interface MfaFactor {
id?: number;
factorType: MfaFactorType;
@@ -135,4 +186,28 @@ export class AccessControlService {
verifyTotp(code: string): Observable<TotpVerifyResponse> {
return this.http.post<TotpVerifyResponse>('/v1/mfa/totp/verify', { code }, { headers: this.silentHeaders });
}
// ───── MFA Logs (enrolment + login audit) ─────
/**
* Unified audit feed for MFA events.
* `tab=enrollment` returns one row per successfully verified factor
* (USER_MFA_FACTOR snapshot). `tab=login` returns every 2FA verification
* attempt at sign-in (OTP_LOG + DSC_CHALLENGE combined).
*
* Filters and pagination are applied server-side per the API spec.
*/
getMfaLogs(tab: MfaLogTab, page = 0, pageSize = 25, filters: MfaLogFilters = {}): Observable<MfaLogPage> {
let params = new HttpParams().set('tab', tab).set('page', String(page)).set('pageSize', String(pageSize));
Object.entries(filters).forEach(
([
k,
v
]) => {
if (v !== undefined && v !== null && v !== '') {
params = params.set(k, String(v));
}
}
);
return this.http.get<MfaLogPage>('/v1/mfa/logs', { params, headers: this.silentHeaders });
}
}
@@ -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/.
-->
<div class="ml-dialog">
<!-- Header -->
<div class="ml-header">
<div class="ml-header-ic">
<fa-icon icon="history"></fa-icon>
</div>
<div class="ml-header-text">
<h2>MFA Activity Logs</h2>
<p>Enrolled factors and 2FA / MFA sign-in attempts</p>
</div>
<button type="button" class="ml-close" (click)="close()" matTooltip="Close">
<fa-icon icon="times"></fa-icon>
</button>
</div>
<!-- Tab strip -->
<div class="ml-tabs">
<button class="ml-tab" [class.active]="activeTab === 'enrollment'" (click)="setTab('enrollment')" type="button">
<fa-icon icon="cog"></fa-icon>
<span>Enrolment</span>
@if (enrollment.state === 'loaded') {
<span class="ml-tab-count">{{ enrollment.total }}</span>
}
</button>
<button class="ml-tab" [class.active]="activeTab === 'login'" (click)="setTab('login')" type="button">
<fa-icon icon="shield-alt"></fa-icon>
<span>2FA / MFA Login</span>
@if (login.state === 'loaded') {
<span class="ml-tab-count">{{ login.total }}</span>
}
</button>
</div>
<!-- Filter bar — shared layout, status options switch per tab -->
<div class="ml-filters">
<div class="ml-filter-search">
<fa-icon icon="search" class="ml-filter-search-ic"></fa-icon>
<input
type="text"
[ngModel]="current.searchInput"
(ngModelChange)="onSearchInput(activeTab, $event)"
placeholder="Search username, destination, OTP reference…"
/>
</div>
<select
class="ml-filter-select"
[ngModel]="current.filters.factorType || ''"
(ngModelChange)="setFilter(activeTab, 'factorType', $event)"
matTooltip="Filter by factor type"
>
<option value="">All factors</option>
@for (f of factorTypes; track f) {
<option [value]="f">{{ factorLabel(f) }}</option>
}
</select>
<select
class="ml-filter-select"
[ngModel]="current.filters.status || ''"
(ngModelChange)="setFilter(activeTab, 'status', $event)"
matTooltip="Filter by status"
>
<option value="">All statuses</option>
@if (activeTab === 'enrollment') {
@for (s of enrollmentStatuses; track s) {
<option [value]="s">{{ s }}</option>
}
} @else {
@for (s of loginStatuses; track s) {
<option [value]="s">{{ s }}</option>
}
}
</select>
<div class="ml-filter-dates">
<label>
<span>From</span>
<input
type="date"
[ngModel]="current.filters.dateFrom || ''"
(ngModelChange)="setFilter(activeTab, 'dateFrom', $event)"
/>
</label>
<label>
<span>To</span>
<input
type="date"
[ngModel]="current.filters.dateTo || ''"
(ngModelChange)="setFilter(activeTab, 'dateTo', $event)"
/>
</label>
</div>
@if (hasActiveFilters(activeTab)) {
<button type="button" class="ml-filter-clear" (click)="clearFilters(activeTab)">
<fa-icon icon="times"></fa-icon> Clear
</button>
}
<button type="button" class="ml-filter-refresh" (click)="refresh(activeTab)" matTooltip="Refresh">
<fa-icon icon="redo"></fa-icon>
</button>
</div>
<!-- Body -->
<div class="ml-body">
<!-- Loading -->
@if (current.state === 'loading') {
<div class="ml-skeleton">
@for (i of skeletonRows(); track i) {
<div class="ml-skel-row">
<span class="ml-skel ml-skel--xs"></span>
<span class="ml-skel ml-skel--md"></span>
<span class="ml-skel ml-skel--sm"></span>
<span class="ml-skel ml-skel--lg"></span>
<span class="ml-skel ml-skel--sm"></span>
</div>
}
</div>
}
<!-- Empty -->
@if (current.state === 'empty') {
<div class="ml-empty">
<div class="ml-empty-ic">
<fa-icon [icon]="activeTab === 'enrollment' ? 'clipboard-list' : 'shield-alt'"></fa-icon>
</div>
@if (activeTab === 'enrollment') {
<h3>No enrolled factors</h3>
<p>
Activity here will populate as factors are verified or removed. Try clearing filters if you're looking for
an older record.
</p>
} @else {
<h3>No sign-in attempts logged</h3>
<p>
2FA / MFA challenges raised at sign-in will appear here — successful verifications, failed codes, expired
sessions, and account locks.
</p>
}
@if (hasActiveFilters(activeTab)) {
<button type="button" class="ml-empty-btn" (click)="clearFilters(activeTab)">
<fa-icon icon="times"></fa-icon> Clear filters
</button>
} @else {
<button type="button" class="ml-empty-btn" (click)="refresh(activeTab)">
<fa-icon icon="redo"></fa-icon> Refresh
</button>
}
</div>
}
<!-- Error -->
@if (current.state === 'error') {
<div class="ml-error">
<fa-icon icon="exclamation-triangle"></fa-icon>
<div>
<strong>Couldn't load logs</strong>
<p>{{ current.error }}</p>
</div>
<button type="button" class="ml-error-btn" (click)="refresh(activeTab)">
<fa-icon icon="redo"></fa-icon> Retry
</button>
</div>
}
<!-- Loaded — Enrolment table -->
@if (current.state === 'loaded' && activeTab === 'enrollment') {
<div class="ml-table-wrap">
<table class="ml-table">
<thead>
<tr>
<th>Event time</th>
<th>User</th>
<th>Factor</th>
<th>Destination</th>
<th>Status</th>
<th>Verified on</th>
<th>Message</th>
</tr>
</thead>
<tbody>
@for (row of enrollment.rows; track $index) {
<tr>
<td class="mono">{{ formatTs(row.eventOn) }}</td>
<td>
<div>{{ row.userFullName || row.username }}</div>
<div class="muted small mono">{{ row.username }}</div>
</td>
<td>
<span class="ml-factor-chip">
<fa-icon [icon]="factorIcon(row.factorType)"></fa-icon>
{{ factorLabel(row.factorType) }}
</span>
</td>
<td class="mono small">{{ row.destination || '—' }}</td>
<td>
<span class="ml-pill" [class]="'ml-pill--' + statusColor(row.status)">{{ row.status }}</span>
</td>
<td class="mono small">{{ formatTs(row.verifiedOn) }}</td>
<td class="small">{{ row.message || '—' }}</td>
</tr>
}
</tbody>
</table>
</div>
}
<!-- Loaded — Login table -->
@if (current.state === 'loaded' && activeTab === 'login') {
<div class="ml-table-wrap">
<table class="ml-table">
<thead>
<tr>
<th>Event time</th>
<th>User</th>
<th>Factor</th>
<th>Destination</th>
<th>Status</th>
<th class="num">Attempts</th>
<th>IP / Device</th>
<th>Message</th>
</tr>
</thead>
<tbody>
@for (row of login.rows; track $index) {
<tr>
<td class="mono">{{ formatTs(row.eventOn) }}</td>
<td>
<div>{{ row.userFullName || row.username }}</div>
<div class="muted small mono">{{ row.username }}</div>
</td>
<td>
<span class="ml-factor-chip">
<fa-icon [icon]="factorIcon(row.factorType)"></fa-icon>
{{ factorLabel(row.factorType) }}
</span>
</td>
<td class="mono small">{{ row.destination || '—' }}</td>
<td>
<span class="ml-pill" [class]="'ml-pill--' + statusColor(row.status)">{{ row.status }}</span>
</td>
<td class="num">{{ row.attempts ?? '—' }}</td>
<td class="mono small">
{{ row.ipAddress || '—' }}
@if (row.userAgent) {
<div class="muted small">{{ row.userAgent }}</div>
}
</td>
<td class="small">{{ row.message || '—' }}</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
<!-- Pagination footer -->
@if (current.state === 'loaded' || (current.state === 'empty' && current.total > 0)) {
<div class="ml-pager">
<div class="ml-pager-info">{{ currentRangeLabel }}</div>
<div class="ml-pager-ctrls">
<label class="ml-pager-pagesize">
Rows
<select [ngModel]="current.pageSize" (ngModelChange)="changePageSize(+$event)">
<option [value]="10">10</option>
<option [value]="25">25</option>
<option [value]="50">50</option>
<option [value]="100">100</option>
</select>
</label>
<button type="button" class="ml-pager-btn" [disabled]="current.page === 0" (click)="prevPage()">
<fa-icon icon="chevron-left"></fa-icon> Prev
</button>
<span class="ml-pager-page">
Page <strong>{{ current.page + 1 }}</strong> of {{ currentTotalPages }}
</span>
<button
type="button"
class="ml-pager-btn"
[disabled]="current.page + 1 >= currentTotalPages"
(click)="nextPage()"
>
Next <fa-icon icon="chevron-right"></fa-icon>
</button>
</div>
</div>
}
</div>
@@ -0,0 +1,702 @@
/**
* 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/.
*/
:host {
--ml-navy: #0b1e3d;
--ml-blue: #1d4ed8;
--ml-blue-l: #eff6ff;
--ml-green: #15803d;
--ml-green-l: #dcfce7;
--ml-amber: #b45309;
--ml-amber-l: #fef3c7;
--ml-red: #c0392b;
--ml-red-l: #fee2e2;
--ml-gray-l: #f1f5f9;
--ml-gray: #64748b;
--ml-g200: #e2e8f0;
--ml-g500: #64748b;
--ml-g700: #334155;
--ml-g900: #0f172a;
display: block;
font-family:
'DM Sans',
system-ui,
-apple-system,
sans-serif;
color: var(--ml-g900);
font-size: 13px;
}
.ml-dialog {
display: flex;
flex-direction: column;
width: 100%;
max-height: 82vh;
background: #fff;
}
/* ───────── HEADER ───────── */
.ml-header {
display: flex;
align-items: center;
gap: 14px;
padding: 14px 18px;
background: linear-gradient(135deg, #0b1e3d 0%, #1d4ed8 100%);
color: #fff;
position: sticky;
top: 0;
z-index: 2;
}
.ml-header-ic {
width: 38px;
height: 38px;
border-radius: 10px;
background: rgb(255 255 255 / 14%);
border: 1px solid rgb(255 255 255 / 22%);
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 17px;
flex-shrink: 0;
}
.ml-header-text {
flex: 1;
h2 {
margin: 0;
font-size: 15px;
font-weight: 700;
letter-spacing: 0.01em;
}
p {
margin: 2px 0 0;
font-size: 11.5px;
color: rgb(255 255 255 / 80%);
}
}
.ml-close {
width: 30px;
height: 30px;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
background: rgb(255 255 255 / 10%);
color: #fff;
border: 1px solid rgb(255 255 255 / 18%);
font-size: 12px;
cursor: pointer;
transition: all 0.15s ease;
&:hover {
background: rgb(255 255 255 / 22%);
border-color: rgb(255 255 255 / 40%);
}
}
/* ───────── TABS ───────── */
.ml-tabs {
display: flex;
gap: 4px;
padding: 10px 14px 0;
background: #fff;
border-bottom: 1px solid var(--ml-g200);
}
.ml-tab {
display: inline-flex;
align-items: center;
gap: 8px;
background: transparent;
border: 0;
border-bottom: 2px solid transparent;
padding: 10px 14px;
font-size: 12.5px;
font-weight: 600;
color: var(--ml-g500);
cursor: pointer;
transition: all 0.15s ease;
font-family: inherit;
margin-bottom: -1px;
fa-icon {
font-size: 12px;
}
&:hover {
color: var(--ml-g700);
}
&.active {
color: var(--ml-blue);
border-bottom-color: var(--ml-blue);
}
}
.ml-tab-count {
font-size: 10px;
font-weight: 700;
padding: 2px 7px;
border-radius: 999px;
background: var(--ml-blue-l);
color: var(--ml-blue);
}
.ml-tab.active .ml-tab-count {
background: var(--ml-blue);
color: #fff;
}
/* ───────── BODY ───────── */
.ml-body {
flex: 1;
overflow-y: auto;
background: var(--ml-gray-l);
padding: 14px;
}
/* ───────── TABLE ───────── */
.ml-table-wrap {
background: #fff;
border-radius: 10px;
overflow: hidden;
border: 1px solid var(--ml-g200);
box-shadow: 0 1px 2px rgb(15 23 42 / 4%);
}
.ml-table {
width: 100%;
border-collapse: collapse;
font-size: 12.5px;
thead th {
background: #f8fafc;
color: var(--ml-g700);
text-align: left;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 9px 12px;
border-bottom: 1px solid var(--ml-g200);
white-space: nowrap;
}
tbody td {
padding: 9px 12px;
border-bottom: 1px solid #f1f5f9;
vertical-align: top;
}
tbody tr:hover {
background: #f8fafc;
}
tbody tr:last-child td {
border-bottom: 0;
}
}
.mono {
font-family: 'JetBrains Mono', ui-monospace, Menlo, Consolas, monospace;
font-size: 12px;
}
.small {
font-size: 11.5px;
}
.muted {
color: var(--ml-g500);
}
/* Factor chip in the cell */
.ml-factor-chip {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 600;
color: var(--ml-g700);
fa-icon {
font-size: 11px;
color: var(--ml-blue);
}
}
/* Numeric cell alignment */
.ml-table .num {
text-align: right;
}
/* Pills */
.ml-pill {
display: inline-flex;
align-items: center;
font-size: 10.5px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 3px 9px;
border-radius: 999px;
&--blue {
background: #eff6ff;
color: #1d4ed8;
border: 1px solid #dbeafe;
}
&--darkred {
background: #450a0a;
color: #fecaca;
border: 1px solid #7f1d1d;
}
&--green {
background: var(--ml-green-l);
color: var(--ml-green);
border: 1px solid #bbf7d0;
}
&--red {
background: var(--ml-red-l);
color: var(--ml-red);
border: 1px solid #fecaca;
}
&--amber {
background: var(--ml-amber-l);
color: var(--ml-amber);
border: 1px solid #fde68a;
}
&--gray {
background: #f1f5f9;
color: var(--ml-g700);
border: 1px solid #e2e8f0;
}
}
/* ───────── SKELETON ───────── */
.ml-skeleton {
background: #fff;
border-radius: 10px;
border: 1px solid var(--ml-g200);
padding: 12px;
overflow: hidden;
}
.ml-skel-row {
display: flex;
align-items: center;
gap: 14px;
padding: 10px 4px;
border-bottom: 1px dashed #eef2f7;
&:last-child {
border-bottom: 0;
}
}
.ml-skel {
height: 10px;
border-radius: 5px;
background: linear-gradient(90deg, #eef2f7 0%, #f8fafc 50%, #eef2f7 100%);
background-size: 200% 100%;
animation: ml-shimmer 1.4s ease-in-out infinite;
&--xs {
width: 12%;
}
&--sm {
width: 16%;
}
&--md {
width: 22%;
}
&--lg {
width: 32%;
}
}
@keyframes ml-shimmer {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
/* ───────── EMPTY ───────── */
.ml-empty {
background: #fff;
border: 1px dashed #cbd5e1;
border-radius: 12px;
padding: 36px 24px;
text-align: center;
}
.ml-empty-ic {
width: 56px;
height: 56px;
border-radius: 14px;
background: var(--ml-blue-l);
color: var(--ml-blue);
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 22px;
margin-bottom: 12px;
}
.ml-empty h3 {
margin: 0 0 6px;
font-size: 15px;
font-weight: 700;
color: var(--ml-g900);
}
.ml-empty p {
margin: 0 0 18px;
font-size: 12.5px;
color: var(--ml-g500);
line-height: 1.5;
max-width: 460px;
margin-left: auto;
margin-right: auto;
}
.ml-empty-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 16px;
border-radius: 999px;
background: var(--ml-blue-l);
color: var(--ml-blue);
border: 1px solid #dbeafe;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: all 0.15s ease;
&:hover {
background: var(--ml-blue);
color: #fff;
border-color: var(--ml-blue);
}
}
/* ───────── ERROR ───────── */
.ml-error {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 14px 16px;
border-radius: 10px;
background: var(--ml-red-l);
border: 1px solid #fecaca;
color: var(--ml-g900);
> fa-icon {
color: var(--ml-red);
font-size: 16px;
margin-top: 2px;
}
> div {
flex: 1;
strong {
font-size: 13px;
color: var(--ml-red);
}
p {
margin: 4px 0 0;
font-size: 12px;
color: var(--ml-g700);
}
}
}
.ml-error-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 14px;
border-radius: 999px;
background: #fff;
color: var(--ml-red);
border: 1px solid #fecaca;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: all 0.15s ease;
flex-shrink: 0;
&:hover {
background: var(--ml-red);
color: #fff;
border-color: var(--ml-red);
}
}
/* ───────── FILTER BAR ───────── */
.ml-filters {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
padding: 10px 14px;
background: #fff;
border-bottom: 1px solid var(--ml-g200);
}
.ml-filter-search {
position: relative;
flex: 1;
min-width: 220px;
input {
width: 100%;
box-sizing: border-box;
padding: 7px 12px 7px 30px;
border: 1px solid var(--ml-g200);
border-radius: 8px;
background: #f8fafc;
font-size: 12.5px;
color: var(--ml-g900);
font-family: inherit;
&::placeholder {
color: #94a3b8;
}
&:focus {
outline: none;
border-color: var(--ml-blue);
background: #fff;
box-shadow: 0 0 0 3px rgb(29 78 216 / 14%);
}
}
}
.ml-filter-search-ic {
position: absolute;
top: 50%;
left: 10px;
transform: translateY(-50%);
font-size: 12px;
color: #94a3b8;
pointer-events: none;
}
.ml-filter-select {
height: 32px;
padding: 0 10px;
border: 1px solid var(--ml-g200);
border-radius: 8px;
background: #fff;
color: var(--ml-g900);
font-size: 12.5px;
font-family: inherit;
cursor: pointer;
&:focus {
outline: none;
border-color: var(--ml-blue);
box-shadow: 0 0 0 3px rgb(29 78 216 / 14%);
}
}
.ml-filter-dates {
display: inline-flex;
align-items: center;
gap: 6px;
label {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 10.5px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--ml-g500);
}
// stylelint-disable-next-line selector-no-qualifying-type
input[type='date'] {
height: 32px;
padding: 0 8px;
border: 1px solid var(--ml-g200);
border-radius: 8px;
background: #fff;
color: var(--ml-g900);
font-size: 12px;
font-family: inherit;
&:focus {
outline: none;
border-color: var(--ml-blue);
box-shadow: 0 0 0 3px rgb(29 78 216 / 14%);
}
}
}
.ml-filter-clear {
display: inline-flex;
align-items: center;
gap: 5px;
background: var(--ml-red-l);
color: var(--ml-red);
border: 1px solid #fecaca;
border-radius: 999px;
padding: 4px 11px;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
cursor: pointer;
fa-icon {
font-size: 10px;
}
&:hover {
background: var(--ml-red);
color: #fff;
border-color: var(--ml-red);
}
}
.ml-filter-refresh {
width: 32px;
height: 32px;
border-radius: 8px;
border: 1px solid var(--ml-g200);
background: #fff;
color: var(--ml-g700);
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 12px;
transition: all 0.15s ease;
&:hover {
background: var(--ml-blue-l);
color: var(--ml-blue);
border-color: #dbeafe;
}
}
/* ───────── PAGINATION FOOTER ───────── */
.ml-pager {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
background: #f8fafc;
border-top: 1px solid var(--ml-g200);
font-size: 12px;
flex-wrap: wrap;
gap: 10px;
}
.ml-pager-info {
color: var(--ml-g500);
font-weight: 600;
}
.ml-pager-ctrls {
display: inline-flex;
align-items: center;
gap: 10px;
}
.ml-pager-pagesize {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 11px;
color: var(--ml-g500);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
select {
height: 28px;
padding: 0 8px;
border: 1px solid var(--ml-g200);
border-radius: 6px;
background: #fff;
font-size: 12px;
font-family: inherit;
color: var(--ml-g900);
cursor: pointer;
&:focus {
outline: none;
border-color: var(--ml-blue);
}
}
}
.ml-pager-btn {
display: inline-flex;
align-items: center;
gap: 5px;
height: 28px;
padding: 0 10px;
border-radius: 6px;
background: #fff;
color: var(--ml-g700);
border: 1px solid var(--ml-g200);
font-size: 12px;
font-weight: 600;
cursor: pointer;
font-family: inherit;
transition: all 0.15s ease;
fa-icon {
font-size: 10px;
}
&:hover:not(:disabled) {
background: var(--ml-blue-l);
color: var(--ml-blue);
border-color: #dbeafe;
}
&:disabled {
opacity: 0.45;
cursor: not-allowed;
}
}
.ml-pager-page {
font-size: 12px;
color: var(--ml-g700);
strong {
color: var(--ml-g900);
}
}
@@ -0,0 +1,319 @@
/**
* 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 { ChangeDetectorRef, Component, inject, OnDestroy, OnInit } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatDialogRef } from '@angular/material/dialog';
import { MatTabsModule } from '@angular/material/tabs';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { Subject, debounceTime } from 'rxjs';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import {
AccessControlService,
MfaLogEntry,
MfaLogFactorType,
MfaLogFilters,
MfaLogStatus,
MfaLogTab
} from '../access-control.service';
type LoadState = 'loading' | 'empty' | 'loaded' | 'error';
interface TabState {
state: LoadState;
rows: MfaLogEntry[];
total: number;
page: number;
pageSize: number;
error: string;
filters: MfaLogFilters;
/** Local copy of search input so we can debounce typing. */
searchInput: string;
}
const ENROLLMENT_STATUSES: MfaLogStatus[] = [
'ACTIVE',
'DISABLED'
];
const LOGIN_STATUSES: MfaLogStatus[] = [
'PENDING',
'SENT',
'VERIFIED',
'FAILED',
'EXPIRED',
'LOCKED'
];
const FACTOR_TYPES: MfaLogFactorType[] = [
'SMS',
'EMAIL',
'TOTP',
'DSC'
];
@Component({
selector: 'mifosx-mfa-logs-dialog',
templateUrl: './mfa-logs-dialog.component.html',
styleUrls: ['./mfa-logs-dialog.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FormsModule,
MatTabsModule,
FaIconComponent
]
})
export class MfaLogsDialogComponent implements OnInit, OnDestroy {
private service = inject(AccessControlService);
private dialogRef = inject(MatDialogRef<MfaLogsDialogComponent>);
private cdr = inject(ChangeDetectorRef);
/** Active tab id. */
activeTab: MfaLogTab = 'enrollment';
/** Dropdown option lists. */
readonly factorTypes = FACTOR_TYPES;
readonly enrollmentStatuses = ENROLLMENT_STATUSES;
readonly loginStatuses = LOGIN_STATUSES;
/** Per-tab state — each tab owns its own filters, page and rows. */
enrollment: TabState = this.makeTabState();
login: TabState = this.makeTabState();
/** Debounce search input so we don't fire a request per keystroke. */
private search$ = new Subject<{ tab: MfaLogTab; value: string }>();
ngOnInit(): void {
this.search$.pipe(debounceTime(350)).subscribe(({ tab, value }) => {
const state = this.stateFor(tab);
state.filters.search = value || undefined;
state.page = 0;
this.load(tab);
});
this.load('enrollment');
this.load('login');
}
ngOnDestroy(): void {
this.search$.complete();
}
// ─────────────── Tab switching ───────────────
setTab(tab: MfaLogTab): void {
this.activeTab = tab;
}
/** Convenience accessor — current tab's state object. */
get current(): TabState {
return this.stateFor(this.activeTab);
}
stateFor(tab: MfaLogTab): TabState {
return tab === 'enrollment' ? this.enrollment : this.login;
}
// ─────────────── Loading ───────────────
load(tab: MfaLogTab): void {
const state = this.stateFor(tab);
state.state = 'loading';
state.error = '';
this.service.getMfaLogs(tab, state.page, state.pageSize, state.filters).subscribe({
next: (res) => {
// Reassign to fresh array refs so Angular's CD reliably picks up the change
// even when the dialog is mid-animation or another async load is in flight.
state.rows = [
...(res?.pageItems || [])
];
state.total = res?.totalFilteredRecords ?? 0;
state.state = state.rows.length === 0 ? 'empty' : 'loaded';
this.cdr.markForCheck();
this.cdr.detectChanges();
},
error: (err) => {
if (err?.status === 404) {
state.rows = [];
state.total = 0;
state.state = 'empty';
this.cdr.detectChanges();
return;
}
state.error =
err?.error?.defaultUserMessage || err?.error?.message || err?.message || 'Could not load MFA logs.';
state.state = 'error';
this.cdr.detectChanges();
}
});
}
refresh(tab: MfaLogTab): void {
this.load(tab);
}
// ─────────────── Filters ───────────────
/** Drop the prior search debounce and fire immediately. */
onSearchInput(tab: MfaLogTab, value: string): void {
const state = this.stateFor(tab);
state.searchInput = value;
this.search$.next({ tab, value: value.trim() });
}
setFilter<K extends keyof MfaLogFilters>(tab: MfaLogTab, key: K, value: MfaLogFilters[K] | ''): void {
const state = this.stateFor(tab);
if (value === '' || value === null || value === undefined) {
delete state.filters[key];
} else {
state.filters[key] = value as MfaLogFilters[K];
}
state.page = 0;
this.load(tab);
}
clearFilters(tab: MfaLogTab): void {
const state = this.stateFor(tab);
state.filters = {};
state.searchInput = '';
state.page = 0;
this.load(tab);
}
hasActiveFilters(tab: MfaLogTab): boolean {
const state = this.stateFor(tab);
return Object.keys(state.filters).length > 0 || !!state.searchInput;
}
// ─────────────── Pagination ───────────────
get currentTotalPages(): number {
const s = this.current;
return Math.max(1, Math.ceil(s.total / s.pageSize));
}
nextPage(): void {
const s = this.current;
if (s.page + 1 >= this.currentTotalPages) return;
s.page++;
this.load(this.activeTab);
}
prevPage(): void {
const s = this.current;
if (s.page <= 0) return;
s.page--;
this.load(this.activeTab);
}
changePageSize(size: number): void {
const s = this.current;
s.pageSize = size;
s.page = 0;
this.load(this.activeTab);
}
get currentRangeLabel(): string {
const s = this.current;
if (s.total === 0) return '0 of 0';
const start = s.page * s.pageSize + 1;
const end = Math.min(s.total, start + s.rows.length - 1);
return `${start}–${end} of ${s.total}`;
}
close(): void {
this.dialogRef.close();
}
// ─────────────── Display helpers ───────────────
formatTs(ts?: string | null): string {
if (!ts) return '—';
const d = new Date(ts);
if (Number.isNaN(d.getTime())) return ts;
const pad = (n: number) => String(n).padStart(2, '0');
return (
d.getFullYear() +
'-' +
pad(d.getMonth() + 1) +
'-' +
pad(d.getDate()) +
' ' +
pad(d.getHours()) +
':' +
pad(d.getMinutes()) +
':' +
pad(d.getSeconds())
);
}
factorLabel(f?: string): string {
switch ((f || '').toUpperCase()) {
case 'SMS':
return 'SMS OTP';
case 'EMAIL':
return 'Email OTP';
case 'TOTP':
return 'Authenticator';
case 'DSC':
return 'DSC';
default:
return f || '';
}
}
/** Matches the 2FA picker icon mapping in two-factor-authentication. */
factorIcon(f?: string): string {
switch ((f || '').toUpperCase()) {
case 'SMS':
return 'mobile-alt';
case 'EMAIL':
return 'envelope';
case 'TOTP':
return 'qrcode';
case 'DSC':
return 'signature';
default:
return 'shield-alt';
}
}
/** Status pill colour per the API spec's suggested mapping. */
statusColor(status?: string): string {
switch ((status || '').toUpperCase()) {
case 'ACTIVE':
case 'VERIFIED':
return 'green';
case 'DISABLED':
return 'gray';
case 'PENDING':
case 'SENT':
return 'blue';
case 'FAILED':
return 'red';
case 'EXPIRED':
return 'amber';
case 'LOCKED':
return 'darkred';
default:
return 'gray';
}
}
/** Skeleton rows for the loading shimmer (count, not data). */
skeletonRows(n = 6): number[] {
return Array.from({ length: n }, (_, i) => i);
}
// ─────────────── Internal ───────────────
private makeTabState(): TabState {
return {
state: 'loading',
rows: [],
total: 0,
page: 0,
pageSize: 25,
error: '',
filters: {},
searchInput: ''
};
}
}