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:
co-authored by
Claude Opus 4.7
parent
4322f37c6d
commit
325228c514
@@ -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 & 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: ''
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user