feat: MFA/2FA, OTP audit logs, KYC + client/user enhancements
- Access Control module: SMS/Email/TOTP/Hardware-token MFA factor management - OTP Audit Logs module under Admin menu (list + detail) with 401/403 handling - Two-factor authentication login flow updates - Client KYC, India address, family-members, ckyc, nominee enhancements - New shared KYC helpers (kyc-proof-types, kyc-upload-helper.service) - User create/edit/view enhancements - Shell navigation: OTP Audit Logs entry under real Admin menu
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* 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 { NgModule } from '@angular/core';
|
||||
import { Routes, RouterModule } from '@angular/router';
|
||||
|
||||
import { Route } from '../core/route/route.service';
|
||||
import { OtpAuditLogsComponent } from './otp-audit-logs.component';
|
||||
import { ViewOtpLogComponent } from './view-otp-log/view-otp-log.component';
|
||||
|
||||
const routes: Routes = [
|
||||
Route.withShell([
|
||||
{
|
||||
path: 'otp-audit-logs',
|
||||
data: { title: 'OTP Audit Logs', breadcrumb: 'OTP Audit Logs' },
|
||||
children: [
|
||||
{ path: '', component: OtpAuditLogsComponent },
|
||||
{
|
||||
path: ':id',
|
||||
component: ViewOtpLogComponent,
|
||||
data: { title: 'OTP Log Detail', breadcrumb: 'Detail' }
|
||||
}
|
||||
]
|
||||
}
|
||||
])
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
imports: [RouterModule.forChild(routes)],
|
||||
exports: [RouterModule]
|
||||
})
|
||||
export class OtpAuditLogsRoutingModule {}
|
||||
@@ -0,0 +1,186 @@
|
||||
<!--
|
||||
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="oal-container">
|
||||
<header class="oal-head">
|
||||
<div class="oal-head__icon"><fa-icon icon="key"></fa-icon></div>
|
||||
<div class="oal-head__text">
|
||||
<h2>OTP Audit Logs</h2>
|
||||
<p>All OTP send, verify and resend events. Used for auditing MFA and login activity.</p>
|
||||
</div>
|
||||
<div class="oal-head__buttons">
|
||||
<button mat-stroked-button class="oal-head__btn" (click)="downloadCsv()" [disabled]="!dataSource.data.length">
|
||||
<mat-icon>download</mat-icon> CSV
|
||||
</button>
|
||||
<button mat-stroked-button class="oal-head__btn" (click)="refresh()"><mat-icon>refresh</mat-icon> Refresh</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Filter bar -->
|
||||
<section class="oal-filters">
|
||||
<mat-form-field appearance="outline" class="oal-ff oal-ff--grow">
|
||||
<mat-label>Search</mat-label>
|
||||
<mat-icon matPrefix>search</mat-icon>
|
||||
<input
|
||||
matInput
|
||||
[(ngModel)]="search"
|
||||
placeholder="Username, mobile, email or reference"
|
||||
(keydown.enter)="applyFilters()"
|
||||
/>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="oal-ff">
|
||||
<mat-label>Status</mat-label>
|
||||
<mat-select [(ngModel)]="status">
|
||||
@for (s of statusOptions; track s.value) {
|
||||
<mat-option [value]="s.value">{{ s.label }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="oal-ff">
|
||||
<mat-label>Channel</mat-label>
|
||||
<mat-select [(ngModel)]="channel">
|
||||
@for (c of channelOptions; track c.value) {
|
||||
<mat-option [value]="c.value">{{ c.label }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="oal-ff">
|
||||
<mat-label>From</mat-label>
|
||||
<input matInput [matDatepicker]="fromPicker" [(ngModel)]="dateFrom" />
|
||||
<mat-datepicker-toggle matSuffix [for]="fromPicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #fromPicker></mat-datepicker>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="oal-ff">
|
||||
<mat-label>To</mat-label>
|
||||
<input matInput [matDatepicker]="toPicker" [(ngModel)]="dateTo" />
|
||||
<mat-datepicker-toggle matSuffix [for]="toPicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #toPicker></mat-datepicker>
|
||||
</mat-form-field>
|
||||
|
||||
<div class="oal-filters__actions">
|
||||
<button mat-flat-button color="primary" (click)="applyFilters()"><mat-icon>filter_list</mat-icon> Apply</button>
|
||||
<button mat-stroked-button (click)="clearFilters()"><mat-icon>clear</mat-icon> Clear</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Table card -->
|
||||
<section class="oal-card">
|
||||
@if (loading) {
|
||||
<mat-progress-bar mode="indeterminate"></mat-progress-bar>
|
||||
}
|
||||
<div class="oal-table-wrap">
|
||||
<table mat-table [dataSource]="dataSource" class="oal-table">
|
||||
<ng-container matColumnDef="id">
|
||||
<th mat-header-cell *matHeaderCellDef>ID</th>
|
||||
<td mat-cell *matCellDef="let row" class="oal-mono">#{{ row.id }}</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="createdOn">
|
||||
<th mat-header-cell *matHeaderCellDef>Created</th>
|
||||
<td mat-cell *matCellDef="let row">{{ formatDate(row.createdOn) }}</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="username">
|
||||
<th mat-header-cell *matHeaderCellDef>User</th>
|
||||
<td mat-cell *matCellDef="let row">{{ row.username || '—' }}</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="channel">
|
||||
<th mat-header-cell *matHeaderCellDef>Channel</th>
|
||||
<td mat-cell *matCellDef="let row">
|
||||
<span class="oal-chip" [attr.data-channel]="row.channel">
|
||||
<mat-icon>{{
|
||||
row.channel === 'EMAIL' ? 'mail' : row.channel === 'TOTP' ? 'qr_code_2' : 'smartphone'
|
||||
}}</mat-icon>
|
||||
{{ row.channel || '—' }}
|
||||
</span>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="destination">
|
||||
<th mat-header-cell *matHeaderCellDef>Destination</th>
|
||||
<td mat-cell *matCellDef="let row" class="oal-mono">{{ row.destination || '—' }}</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="status">
|
||||
<th mat-header-cell *matHeaderCellDef>Status</th>
|
||||
<td mat-cell *matCellDef="let row">
|
||||
<span class="oal-status" [ngClass]="statusClass(row.status)">{{ row.status || '—' }}</span>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="attempts">
|
||||
<th mat-header-cell *matHeaderCellDef>Attempts</th>
|
||||
<td mat-cell *matCellDef="let row">
|
||||
{{ row.attempts ?? 0 }}<span class="oal-dim">{{ row.maxAttempts ? '/' + row.maxAttempts : '' }}</span>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="verifiedOn">
|
||||
<th mat-header-cell *matHeaderCellDef>Verified</th>
|
||||
<td mat-cell *matCellDef="let row">{{ formatDate(row.verifiedOn) }}</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="actions">
|
||||
<th mat-header-cell *matHeaderCellDef class="oal-actions">Actions</th>
|
||||
<td mat-cell *matCellDef="let row" class="oal-actions">
|
||||
<button mat-icon-button matTooltip="View details" (click)="view(row)">
|
||||
<mat-icon>visibility</mat-icon>
|
||||
</button>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: displayedColumns" (click)="view(row)" class="oal-row"></tr>
|
||||
</table>
|
||||
|
||||
@if (!loading && errorStatus) {
|
||||
<div class="oal-error">
|
||||
<mat-icon>{{ errorStatus === 401 || errorStatus === 403 ? 'lock' : 'error_outline' }}</mat-icon>
|
||||
<div>
|
||||
<strong>
|
||||
@if (errorStatus === 403) {
|
||||
Access denied (HTTP 403)
|
||||
} @else if (errorStatus === 401) {
|
||||
Sign in required (HTTP 401)
|
||||
} @else {
|
||||
Failed to load (HTTP {{ errorStatus }})
|
||||
}
|
||||
</strong>
|
||||
<span>{{ errorMsg }}</span>
|
||||
@if (errorStatus === 403) {
|
||||
<span class="oal-error__hint">
|
||||
Your role doesn't have permission to read OTP logs. Ask an admin to add the
|
||||
<code>READ_OTP_LOG</code> permission to your role under
|
||||
<em>Admin → System → Manage Roles and Permissions</em>.
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
} @else if (!loading && dataSource.data.length === 0) {
|
||||
<div class="oal-empty">
|
||||
<mat-icon>inbox</mat-icon>
|
||||
<p>No OTP logs match the current filters.</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<mat-paginator
|
||||
[length]="totalElements"
|
||||
[pageSize]="pageSize"
|
||||
[pageSizeOptions]="[10, 25, 50, 100]"
|
||||
[pageIndex]="pageIndex"
|
||||
(page)="onPage($event)"
|
||||
showFirstLastButtons
|
||||
></mat-paginator>
|
||||
</section>
|
||||
</div>
|
||||
@@ -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/.
|
||||
*/
|
||||
|
||||
.oal-container {
|
||||
padding: 18px 22px 26px;
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.oal-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 18px 22px;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(135deg, #eff6ff 0%, #fff 60%);
|
||||
border: 1px solid #dbeafe;
|
||||
margin-bottom: 18px;
|
||||
|
||||
&__icon {
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
border-radius: 12px;
|
||||
background: #1e40af;
|
||||
color: #fff;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
h2 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: 12.5px;
|
||||
color: #64748b;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
&__buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
&__btn mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin-right: 6px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
|
||||
.oal-filters {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr 1fr 1fr 1fr auto;
|
||||
align-items: start;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
@media (width <= 1100px) {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
|
||||
button mat-icon {
|
||||
font-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-right: 4px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.oal-ff {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
|
||||
&--grow {
|
||||
@media (width >= 1101px) {
|
||||
grid-column: span 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.oal-card {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 14px;
|
||||
background: #fff;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.oal-table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.oal-table {
|
||||
width: 100%;
|
||||
min-width: 900px;
|
||||
|
||||
th {
|
||||
background: #f8fafc;
|
||||
color: #475569;
|
||||
font-size: 11.5px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.4px;
|
||||
}
|
||||
|
||||
td {
|
||||
font-size: 13px;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.oal-mono {
|
||||
font-family: 'SF Mono', 'Cascadia Code', Consolas, monospace;
|
||||
font-weight: 600;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.oal-actions {
|
||||
width: 56px;
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
|
||||
.oal-row {
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: #eff6ff;
|
||||
}
|
||||
}
|
||||
|
||||
.oal-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 3px 10px;
|
||||
border-radius: 999px;
|
||||
background: #eef2ff;
|
||||
color: #3730a3;
|
||||
border: 1px solid #c7d2fe;
|
||||
font-size: 11.5px;
|
||||
font-weight: 700;
|
||||
|
||||
mat-icon {
|
||||
font-size: 14px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
&[data-channel='EMAIL'] {
|
||||
background: #cffafe;
|
||||
color: #155e75;
|
||||
border-color: #a5f3fc;
|
||||
}
|
||||
|
||||
&[data-channel='TOTP'] {
|
||||
background: #ede9fe;
|
||||
color: #5b21b6;
|
||||
border-color: #ddd6fe;
|
||||
}
|
||||
}
|
||||
|
||||
.oal-status {
|
||||
display: inline-block;
|
||||
padding: 3px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.4px;
|
||||
text-transform: uppercase;
|
||||
background: #f1f5f9;
|
||||
color: #475569;
|
||||
border: 1px solid #e2e8f0;
|
||||
|
||||
&.is-ok {
|
||||
background: #dcfce7;
|
||||
color: #166534;
|
||||
border-color: #bbf7d0;
|
||||
}
|
||||
|
||||
&.is-warn {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
border-color: #fde68a;
|
||||
}
|
||||
|
||||
&.is-bad {
|
||||
background: #fee2e2;
|
||||
color: #b91c1c;
|
||||
border-color: #fecaca;
|
||||
}
|
||||
}
|
||||
|
||||
.oal-dim {
|
||||
color: #94a3b8;
|
||||
font-weight: 400;
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.oal-error {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
margin: 16px;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid #fecaca;
|
||||
background: #fef2f2;
|
||||
border-radius: 12px;
|
||||
color: #b91c1c;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
|
||||
> mat-icon {
|
||||
font-size: 22px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
color: #dc2626;
|
||||
flex-shrink: 0;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
> div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
strong {
|
||||
color: #991b1b;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
&__hint {
|
||||
color: #475569;
|
||||
font-size: 12.5px;
|
||||
|
||||
code {
|
||||
font-family: 'SF Mono', 'Cascadia Code', Consolas, monospace;
|
||||
background: #fff;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #fecaca;
|
||||
color: #991b1b;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
em {
|
||||
font-style: italic;
|
||||
color: #1e40af;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.oal-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 40px 20px;
|
||||
color: #94a3b8;
|
||||
font-size: 13px;
|
||||
|
||||
mat-icon {
|
||||
font-size: 36px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Component, OnInit, ViewChild, inject } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Router, RouterLink } from '@angular/router';
|
||||
import {
|
||||
MatTableDataSource,
|
||||
MatTable,
|
||||
MatColumnDef,
|
||||
MatHeaderCellDef,
|
||||
MatHeaderCell,
|
||||
MatCellDef,
|
||||
MatCell,
|
||||
MatHeaderRowDef,
|
||||
MatHeaderRow,
|
||||
MatRowDef,
|
||||
MatRow
|
||||
} from '@angular/material/table';
|
||||
import { MatPaginator, PageEvent } from '@angular/material/paginator';
|
||||
import { MatSort } from '@angular/material/sort';
|
||||
import { MatProgressBar } from '@angular/material/progress-bar';
|
||||
import { MatButton, MatIconButton } from '@angular/material/button';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatFormField, MatLabel, MatPrefix } from '@angular/material/form-field';
|
||||
import { MatInput } from '@angular/material/input';
|
||||
import { MatSelect } from '@angular/material/select';
|
||||
import { MatOption } from '@angular/material/core';
|
||||
import { MatDatepicker, MatDatepickerInput, MatDatepickerToggle } from '@angular/material/datepicker';
|
||||
import { MatNativeDateModule } from '@angular/material/core';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
import { OtpAuditLogsService, OtpLog, OtpLogPage } from './otp-audit-logs.service';
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-otp-audit-logs',
|
||||
templateUrl: './otp-audit-logs.component.html',
|
||||
styleUrls: ['./otp-audit-logs.component.scss'],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
FormsModule,
|
||||
RouterLink,
|
||||
MatTable,
|
||||
MatColumnDef,
|
||||
MatHeaderCellDef,
|
||||
MatHeaderCell,
|
||||
MatCellDef,
|
||||
MatCell,
|
||||
MatHeaderRowDef,
|
||||
MatHeaderRow,
|
||||
MatRowDef,
|
||||
MatRow,
|
||||
MatPaginator,
|
||||
MatProgressBar,
|
||||
MatButton,
|
||||
MatIconButton,
|
||||
MatIcon,
|
||||
MatFormField,
|
||||
MatLabel,
|
||||
MatPrefix,
|
||||
MatInput,
|
||||
MatSelect,
|
||||
MatOption,
|
||||
MatDatepicker,
|
||||
MatDatepickerInput,
|
||||
MatDatepickerToggle,
|
||||
MatNativeDateModule,
|
||||
FaIconComponent
|
||||
]
|
||||
})
|
||||
export class OtpAuditLogsComponent implements OnInit {
|
||||
private service = inject(OtpAuditLogsService);
|
||||
private router = inject(Router);
|
||||
|
||||
@ViewChild(MatPaginator) paginator!: MatPaginator;
|
||||
|
||||
loading = false;
|
||||
totalElements = 0;
|
||||
dataSource = new MatTableDataSource<OtpLog>([]);
|
||||
/** Last error returned by the listing endpoint (if any) */
|
||||
errorStatus = 0;
|
||||
errorMsg = '';
|
||||
|
||||
displayedColumns: string[] = [
|
||||
'id',
|
||||
'createdOn',
|
||||
'username',
|
||||
'channel',
|
||||
'destination',
|
||||
'status',
|
||||
'attempts',
|
||||
'verifiedOn',
|
||||
'actions'
|
||||
];
|
||||
|
||||
/** Filter state */
|
||||
search = '';
|
||||
status = '';
|
||||
channel = '';
|
||||
dateFrom: Date | null = null;
|
||||
dateTo: Date | null = null;
|
||||
pageSize = 25;
|
||||
pageIndex = 0;
|
||||
|
||||
readonly statusOptions = [
|
||||
{ value: '', label: 'All' },
|
||||
{ value: 'PENDING', label: 'Pending' },
|
||||
{ value: 'VERIFIED', label: 'Verified' },
|
||||
{ value: 'FAILED', label: 'Failed' },
|
||||
{ value: 'EXPIRED', label: 'Expired' },
|
||||
{ value: 'LOCKED', label: 'Locked' }
|
||||
];
|
||||
|
||||
readonly channelOptions = [
|
||||
{ value: '', label: 'All' },
|
||||
{ value: 'SMS', label: 'SMS' },
|
||||
{ value: 'EMAIL', label: 'Email' },
|
||||
{ value: 'TOTP', label: 'Authenticator' }
|
||||
];
|
||||
|
||||
ngOnInit(): void {
|
||||
this.fetch();
|
||||
}
|
||||
|
||||
fetch(): void {
|
||||
this.loading = true;
|
||||
this.errorStatus = 0;
|
||||
this.errorMsg = '';
|
||||
this.service
|
||||
.list({
|
||||
page: this.pageIndex,
|
||||
pageSize: this.pageSize,
|
||||
search: this.search?.trim() || undefined,
|
||||
status: this.status || undefined,
|
||||
channel: this.channel || undefined,
|
||||
dateFrom: this.toIso(this.dateFrom),
|
||||
dateTo: this.toIso(this.dateTo)
|
||||
})
|
||||
.subscribe({
|
||||
next: (res: OtpLogPage | OtpLog[]) => {
|
||||
this.loading = false;
|
||||
if (Array.isArray(res)) {
|
||||
this.totalElements = res.length;
|
||||
this.dataSource.data = res;
|
||||
} else {
|
||||
this.totalElements = res?.totalElements ?? res?.pageItems?.length ?? 0;
|
||||
this.dataSource.data = res?.pageItems || [];
|
||||
}
|
||||
},
|
||||
error: (err) => {
|
||||
this.loading = false;
|
||||
this.dataSource.data = [];
|
||||
this.totalElements = 0;
|
||||
this.errorStatus = err?.status || 0;
|
||||
this.errorMsg =
|
||||
err?.error?.defaultUserMessage || err?.error?.message || err?.message || 'Failed to load OTP audit logs.';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
applyFilters(): void {
|
||||
this.pageIndex = 0;
|
||||
if (this.paginator) this.paginator.firstPage();
|
||||
this.fetch();
|
||||
}
|
||||
|
||||
clearFilters(): void {
|
||||
this.search = '';
|
||||
this.status = '';
|
||||
this.channel = '';
|
||||
this.dateFrom = null;
|
||||
this.dateTo = null;
|
||||
this.applyFilters();
|
||||
}
|
||||
|
||||
onPage(e: PageEvent): void {
|
||||
this.pageIndex = e.pageIndex;
|
||||
this.pageSize = e.pageSize;
|
||||
this.fetch();
|
||||
}
|
||||
|
||||
view(log: OtpLog): void {
|
||||
if (log?.id != null)
|
||||
this.router.navigate([
|
||||
'/otp-audit-logs',
|
||||
log.id
|
||||
]);
|
||||
}
|
||||
|
||||
refresh(): void {
|
||||
this.fetch();
|
||||
}
|
||||
|
||||
/** Export the currently displayed page as a CSV file. */
|
||||
downloadCsv(): void {
|
||||
const rows = this.dataSource.data || [];
|
||||
if (!rows.length) return;
|
||||
const headers = [
|
||||
'ID',
|
||||
'Created',
|
||||
'User',
|
||||
'Channel',
|
||||
'Destination',
|
||||
'Status',
|
||||
'Purpose',
|
||||
'Attempts',
|
||||
'Verified On',
|
||||
'IP',
|
||||
'Reference'
|
||||
];
|
||||
const escape = (v: any) => {
|
||||
if (v == null) return '';
|
||||
const s = String(v).replace(/"/g, '""');
|
||||
return /[",\n]/.test(s) ? `"${s}"` : s;
|
||||
};
|
||||
const lines = [headers.join(',')];
|
||||
for (const r of rows) {
|
||||
lines.push(
|
||||
[
|
||||
r.id,
|
||||
this.formatDate(r.createdOn),
|
||||
r.username,
|
||||
r.channel,
|
||||
r.destination,
|
||||
r.status,
|
||||
r.purpose,
|
||||
r.attempts,
|
||||
this.formatDate(r.verifiedOn),
|
||||
r.ipAddress,
|
||||
r.otpReference
|
||||
]
|
||||
.map(escape)
|
||||
.join(',')
|
||||
);
|
||||
}
|
||||
const blob = new Blob([lines.join('\n')], { type: 'text/csv;charset=utf-8;' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `otp-audit-logs-${new Date().toISOString().slice(0, 10)}.csv`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
/** Format date arrays/strings into a friendly local string */
|
||||
formatDate(value: any): string {
|
||||
if (!value) return '—';
|
||||
if (Array.isArray(value) && value.length >= 3) {
|
||||
const [
|
||||
y,
|
||||
m,
|
||||
d,
|
||||
h = 0,
|
||||
mi = 0,
|
||||
s = 0
|
||||
] = value;
|
||||
const dt = new Date(y, m - 1, d, h, mi, s);
|
||||
return dt.toLocaleString();
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const dt = new Date(value);
|
||||
return isNaN(dt.getTime()) ? value : dt.toLocaleString();
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
statusClass(status?: string): string {
|
||||
if (!status) return '';
|
||||
const s = status.toUpperCase();
|
||||
if (s === 'VERIFIED') return 'is-ok';
|
||||
if (s === 'PENDING') return 'is-warn';
|
||||
if (s === 'FAILED' || s === 'EXPIRED' || s === 'LOCKED') return 'is-bad';
|
||||
return '';
|
||||
}
|
||||
|
||||
private toIso(d: Date | null): string | undefined {
|
||||
if (!d) return undefined;
|
||||
const yyyy = d.getFullYear();
|
||||
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const dd = String(d.getDate()).padStart(2, '0');
|
||||
return `${yyyy}-${mm}-${dd}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* 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 { NgModule } from '@angular/core';
|
||||
import { SharedModule } from '../shared/shared.module';
|
||||
import { OtpAuditLogsRoutingModule } from './otp-audit-logs-routing.module';
|
||||
import { OtpAuditLogsComponent } from './otp-audit-logs.component';
|
||||
import { ViewOtpLogComponent } from './view-otp-log/view-otp-log.component';
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
SharedModule,
|
||||
OtpAuditLogsRoutingModule,
|
||||
OtpAuditLogsComponent,
|
||||
ViewOtpLogComponent
|
||||
]
|
||||
})
|
||||
export class OtpAuditLogsModule {}
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* 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 { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
export interface OtpLog {
|
||||
id: number;
|
||||
username?: string;
|
||||
channel?: string; // SMS | EMAIL | TOTP
|
||||
destination?: string; // mobile or email (masked from API ideally)
|
||||
otpReference?: string;
|
||||
status?: string; // PENDING | VERIFIED | FAILED | EXPIRED | LOCKED
|
||||
purpose?: string; // LOGIN | MFA_SETUP | TXN_APPROVAL etc.
|
||||
attempts?: number;
|
||||
maxAttempts?: number;
|
||||
createdOn?: string | number[];
|
||||
expiresOn?: string | number[];
|
||||
verifiedOn?: string | number[];
|
||||
ipAddress?: string;
|
||||
userAgent?: string;
|
||||
message?: string; // last status message from upstream gateway
|
||||
}
|
||||
|
||||
export interface OtpLogPage {
|
||||
totalElements: number;
|
||||
pageItems: OtpLog[];
|
||||
}
|
||||
|
||||
export interface OtpLogQuery {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
search?: string;
|
||||
status?: string;
|
||||
channel?: string;
|
||||
dateFrom?: string; // ISO yyyy-MM-dd
|
||||
dateTo?: string;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class OtpAuditLogsService {
|
||||
private http = inject(HttpClient);
|
||||
|
||||
/** Paginated list — falls back to a plain array if backend isn't paged yet. */
|
||||
list(query: OtpLogQuery = {}): Observable<OtpLogPage | OtpLog[]> {
|
||||
let params = new HttpParams();
|
||||
if (query.page != null) params = params.set('page', String(query.page));
|
||||
if (query.pageSize != null) params = params.set('pageSize', String(query.pageSize));
|
||||
if (query.search) params = params.set('search', query.search);
|
||||
if (query.status) params = params.set('status', query.status);
|
||||
if (query.channel) params = params.set('channel', query.channel);
|
||||
if (query.dateFrom) params = params.set('dateFrom', query.dateFrom);
|
||||
if (query.dateTo) params = params.set('dateTo', query.dateTo);
|
||||
return this.http.get<OtpLogPage | OtpLog[]>('/otp-logs', { params });
|
||||
}
|
||||
|
||||
/** Detail view */
|
||||
get(id: number | string): Observable<OtpLog> {
|
||||
return this.http.get<OtpLog>(`/otp-logs/${id}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
<!--
|
||||
Copyright since 2025 Mifos Initiative
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<div class="vol-container">
|
||||
<button mat-stroked-button class="vol-back" (click)="back()"><mat-icon>arrow_back</mat-icon> All OTP logs</button>
|
||||
|
||||
@if (loading) {
|
||||
<mat-progress-bar mode="indeterminate"></mat-progress-bar>
|
||||
}
|
||||
|
||||
@if (!loading && error) {
|
||||
<div class="vol-error"><mat-icon>error_outline</mat-icon> {{ error }}</div>
|
||||
}
|
||||
|
||||
@if (!loading && log) {
|
||||
<div class="vol-card">
|
||||
<header class="vol-head">
|
||||
<div class="vol-head__icon">
|
||||
<mat-icon>{{
|
||||
log.channel === 'EMAIL' ? 'mail' : log.channel === 'TOTP' ? 'qr_code_2' : 'smartphone'
|
||||
}}</mat-icon>
|
||||
</div>
|
||||
<div class="vol-head__title">
|
||||
<h2>
|
||||
OTP Log <span class="vol-mono">#{{ log.id }}</span>
|
||||
</h2>
|
||||
<span class="vol-meta">
|
||||
<span class="vol-chip">{{ log.channel || '—' }}</span>
|
||||
<span class="vol-status" [ngClass]="statusClass(log.status)">{{ log.status || '—' }}</span>
|
||||
@if (log.purpose) {
|
||||
<span class="vol-chip vol-chip--purpose">{{ log.purpose }}</span>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="vol-grid">
|
||||
<div class="vol-cell">
|
||||
<span class="vol-cell__label">User</span>
|
||||
<span class="vol-cell__value">{{ log.username || '—' }}</span>
|
||||
</div>
|
||||
<div class="vol-cell">
|
||||
<span class="vol-cell__label">Destination</span>
|
||||
<span class="vol-cell__value vol-mono">{{ log.destination || '—' }}</span>
|
||||
</div>
|
||||
<div class="vol-cell">
|
||||
<span class="vol-cell__label">Reference</span>
|
||||
<span class="vol-cell__value vol-mono">{{ log.otpReference || '—' }}</span>
|
||||
</div>
|
||||
<div class="vol-cell">
|
||||
<span class="vol-cell__label">Attempts</span>
|
||||
<span class="vol-cell__value">
|
||||
{{ log.attempts ?? 0 }}<span class="vol-dim">{{ log.maxAttempts ? '/' + log.maxAttempts : '' }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="vol-cell">
|
||||
<span class="vol-cell__label">Created on</span>
|
||||
<span class="vol-cell__value">{{ formatDate(log.createdOn) }}</span>
|
||||
</div>
|
||||
<div class="vol-cell">
|
||||
<span class="vol-cell__label">Expires on</span>
|
||||
<span class="vol-cell__value">{{ formatDate(log.expiresOn) }}</span>
|
||||
</div>
|
||||
<div class="vol-cell">
|
||||
<span class="vol-cell__label">Verified on</span>
|
||||
<span class="vol-cell__value">{{ formatDate(log.verifiedOn) }}</span>
|
||||
</div>
|
||||
<div class="vol-cell">
|
||||
<span class="vol-cell__label">IP address</span>
|
||||
<span class="vol-cell__value vol-mono">{{ log.ipAddress || '—' }}</span>
|
||||
</div>
|
||||
@if (log.userAgent) {
|
||||
<div class="vol-cell vol-cell--full">
|
||||
<span class="vol-cell__label">User agent</span>
|
||||
<span class="vol-cell__value vol-cell__value--small">{{ log.userAgent }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (log.message) {
|
||||
<div class="vol-cell vol-cell--full">
|
||||
<span class="vol-cell__label">Gateway message</span>
|
||||
<span class="vol-cell__value vol-cell__value--small">{{ log.message }}</span>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
.vol-container {
|
||||
padding: 18px 22px;
|
||||
max-width: 980px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.vol-back {
|
||||
margin-bottom: 14px !important;
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin-right: 6px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
|
||||
.vol-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
border-radius: 12px;
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
color: #b91c1c;
|
||||
font-size: 13px;
|
||||
margin-top: 10px;
|
||||
|
||||
mat-icon {
|
||||
color: #dc2626;
|
||||
}
|
||||
}
|
||||
|
||||
.vol-card {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 14px;
|
||||
background: #fff;
|
||||
padding: 18px 20px 22px;
|
||||
}
|
||||
|
||||
.vol-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding-bottom: 14px;
|
||||
margin-bottom: 16px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
|
||||
&__icon {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border-radius: 14px;
|
||||
background: #1e40af;
|
||||
color: #fff;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
mat-icon {
|
||||
font-size: 24px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
&__title h2 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
}
|
||||
}
|
||||
|
||||
.vol-mono {
|
||||
font-family: 'SF Mono', 'Cascadia Code', Consolas, monospace;
|
||||
color: #0f172a;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.vol-meta {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.vol-chip {
|
||||
display: inline-block;
|
||||
padding: 2px 10px;
|
||||
border-radius: 999px;
|
||||
background: #eef2ff;
|
||||
color: #3730a3;
|
||||
border: 1px solid #c7d2fe;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.4px;
|
||||
text-transform: uppercase;
|
||||
|
||||
&--purpose {
|
||||
background: #ecfeff;
|
||||
color: #155e75;
|
||||
border-color: #a5f3fc;
|
||||
}
|
||||
}
|
||||
|
||||
.vol-status {
|
||||
display: inline-block;
|
||||
padding: 2px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.4px;
|
||||
text-transform: uppercase;
|
||||
background: #f1f5f9;
|
||||
color: #475569;
|
||||
border: 1px solid #e2e8f0;
|
||||
|
||||
&.is-ok {
|
||||
background: #dcfce7;
|
||||
color: #166534;
|
||||
border-color: #bbf7d0;
|
||||
}
|
||||
|
||||
&.is-warn {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
border-color: #fde68a;
|
||||
}
|
||||
|
||||
&.is-bad {
|
||||
background: #fee2e2;
|
||||
color: #b91c1c;
|
||||
border-color: #fecaca;
|
||||
}
|
||||
}
|
||||
|
||||
.vol-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 14px 18px;
|
||||
|
||||
@media (width <= 640px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.vol-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
|
||||
&--full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
color: #64748b;
|
||||
letter-spacing: 0.4px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
&__value {
|
||||
font-size: 13.5px;
|
||||
color: #0f172a;
|
||||
word-break: break-word;
|
||||
|
||||
&--small {
|
||||
font-size: 12px;
|
||||
color: #475569;
|
||||
line-height: 1.5;
|
||||
font-family: 'SF Mono', 'Cascadia Code', Consolas, monospace;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.vol-dim {
|
||||
color: #94a3b8;
|
||||
font-weight: 400;
|
||||
margin-left: 2px;
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { MatProgressBar } from '@angular/material/progress-bar';
|
||||
import { MatButton, MatIconButton } from '@angular/material/button';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
import { OtpAuditLogsService, OtpLog } from '../otp-audit-logs.service';
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-view-otp-log',
|
||||
templateUrl: './view-otp-log.component.html',
|
||||
styleUrls: ['./view-otp-log.component.scss'],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
MatProgressBar,
|
||||
MatButton,
|
||||
MatIconButton,
|
||||
MatIcon,
|
||||
FaIconComponent
|
||||
]
|
||||
})
|
||||
export class ViewOtpLogComponent implements OnInit {
|
||||
private route = inject(ActivatedRoute);
|
||||
private router = inject(Router);
|
||||
private service = inject(OtpAuditLogsService);
|
||||
|
||||
loading = true;
|
||||
log: OtpLog | null = null;
|
||||
error = '';
|
||||
|
||||
ngOnInit(): void {
|
||||
const id = this.route.snapshot.paramMap.get('id');
|
||||
if (!id) {
|
||||
this.error = 'Invalid log id';
|
||||
this.loading = false;
|
||||
return;
|
||||
}
|
||||
this.service.get(id).subscribe({
|
||||
next: (res: OtpLog) => {
|
||||
this.loading = false;
|
||||
this.log = res;
|
||||
},
|
||||
error: (err) => {
|
||||
this.loading = false;
|
||||
this.error = err?.error?.message || err?.error?.defaultUserMessage || 'Could not load log';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
back(): void {
|
||||
this.router.navigate(['/otp-audit-logs']);
|
||||
}
|
||||
|
||||
formatDate(value: any): string {
|
||||
if (!value) return '—';
|
||||
if (Array.isArray(value) && value.length >= 3) {
|
||||
const [
|
||||
y,
|
||||
m,
|
||||
d,
|
||||
h = 0,
|
||||
mi = 0,
|
||||
s = 0
|
||||
] = value;
|
||||
return new Date(y, m - 1, d, h, mi, s).toLocaleString();
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const dt = new Date(value);
|
||||
return isNaN(dt.getTime()) ? value : dt.toLocaleString();
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
statusClass(status?: string): string {
|
||||
if (!status) return '';
|
||||
const s = status.toUpperCase();
|
||||
if (s === 'VERIFIED') return 'is-ok';
|
||||
if (s === 'PENDING') return 'is-warn';
|
||||
return 'is-bad';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user