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:
Aditya
2026-05-10 13:24:48 +05:30
parent 2d4fb4237e
commit 3b752dc6ae
56 changed files with 7741 additions and 599 deletions
@@ -0,0 +1,29 @@
/**
* 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 { AccessControlComponent } from './access-control.component';
const routes: Routes = [
Route.withShell([
{
path: 'access-control',
component: AccessControlComponent,
data: { title: 'Access Control', breadcrumb: 'Access Control' }
}
])
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class AccessControlRoutingModule {}
@@ -0,0 +1,843 @@
<!--
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="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>
<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) {
<section class="ac-section">
<header class="ac-section__head">
<div class="ac-section__num"><mat-icon>add_task</mat-icon></div>
<div>
<h3 class="ac-section__title">Select an authentication method</h3>
<p class="ac-section__sub">
Choose how you want to confirm sign-in. Only one method may be active at a time.
</p>
</div>
</header>
@if (activeMethod) {
<div class="ac-active-banner">
<mat-icon>info</mat-icon>
<span>
<strong>{{
activeMethod === 'sms'
? 'SMS OTP'
: activeMethod === 'email'
? 'Email OTP'
: activeMethod === 'totp'
? 'Authenticator App'
: 'Hardware Token'
}}</strong>
is currently active. Disable it first to switch to a different method.
</span>
</div>
}
<div class="ac-picker">
<!-- SMS -->
<button
class="ac-tile"
type="button"
[class.ac-tile--locked]="isMethodLocked('sms')"
[class.ac-tile--selected]="isMethodPicked('sms') || activeMethod === 'sms'"
[class.ac-tile--active]="activeMethod === 'sms'"
[disabled]="isMethodLocked('sms')"
(click)="pickMethod('sms')"
>
<div class="ac-tile__top">
<div class="ac-tile__icon ac-tile__icon--blue"><mat-icon>smartphone</mat-icon></div>
<span
class="ac-tile__radio"
[class.is-on]="isMethodPicked('sms') || activeMethod === 'sms'"
[class.is-locked]="isMethodLocked('sms')"
[attr.aria-checked]="isMethodPicked('sms') || activeMethod === 'sms'"
role="radio"
>
@if (isMethodLocked('sms')) {
<mat-icon>lock</mat-icon>
} @else if (isMethodPicked('sms') || activeMethod === 'sms') {
<mat-icon>check</mat-icon>
}
</span>
</div>
<h4 class="ac-tile__title">SMS OTP</h4>
<p class="ac-tile__desc">Receive a one-time code on your registered mobile number.</p>
@if (isMethodLocked('sms')) {
<span class="ac-tile__cta ac-tile__cta--locked"> <mat-icon>lock</mat-icon> Locked </span>
} @else {
<span class="ac-tile__cta">
{{ sms.verified ? 'Manage' : 'Set up' }} <mat-icon>arrow_forward</mat-icon>
</span>
}
<span class="ac-type-pill ac-tile__type">Type · SMS</span>
</button>
<!-- Email -->
<button
class="ac-tile"
type="button"
[class.ac-tile--locked]="isMethodLocked('email')"
[class.ac-tile--selected]="isMethodPicked('email') || activeMethod === 'email'"
[class.ac-tile--active]="activeMethod === 'email'"
[disabled]="isMethodLocked('email')"
(click)="pickMethod('email')"
>
<div class="ac-tile__top">
<div class="ac-tile__icon ac-tile__icon--cyan"><mat-icon>mail</mat-icon></div>
<span
class="ac-tile__radio"
[class.is-on]="isMethodPicked('email') || activeMethod === 'email'"
[class.is-locked]="isMethodLocked('email')"
[attr.aria-checked]="isMethodPicked('email') || activeMethod === 'email'"
role="radio"
>
@if (isMethodLocked('email')) {
<mat-icon>lock</mat-icon>
} @else if (isMethodPicked('email') || activeMethod === 'email') {
<mat-icon>check</mat-icon>
}
</span>
</div>
<h4 class="ac-tile__title">Email OTP</h4>
<p class="ac-tile__desc">Receive a one-time code on your verified email address.</p>
@if (isMethodLocked('email')) {
<span class="ac-tile__cta ac-tile__cta--locked"> <mat-icon>lock</mat-icon> Locked </span>
} @else {
<span class="ac-tile__cta">
{{ email.verified ? 'Manage' : 'Set up' }} <mat-icon>arrow_forward</mat-icon>
</span>
}
<span class="ac-type-pill ac-tile__type">Type · EMAIL</span>
</button>
<!-- TOTP -->
<button
class="ac-tile"
type="button"
[class.ac-tile--locked]="isMethodLocked('totp')"
[class.ac-tile--selected]="isMethodPicked('totp') || activeMethod === 'totp'"
[class.ac-tile--active]="activeMethod === 'totp'"
[disabled]="isMethodLocked('totp')"
(click)="pickMethod('totp')"
>
<div class="ac-tile__top">
<div class="ac-tile__icon ac-tile__icon--violet"><mat-icon>qr_code_2</mat-icon></div>
<span
class="ac-tile__radio"
[class.is-on]="isMethodPicked('totp') || activeMethod === 'totp'"
[class.is-locked]="isMethodLocked('totp')"
[attr.aria-checked]="isMethodPicked('totp') || activeMethod === 'totp'"
role="radio"
>
@if (isMethodLocked('totp')) {
<mat-icon>lock</mat-icon>
} @else if (isMethodPicked('totp') || activeMethod === 'totp') {
<mat-icon>check</mat-icon>
}
</span>
</div>
<h4 class="ac-tile__title">Authenticator App</h4>
<p class="ac-tile__desc">Use Google Authenticator, Microsoft Authenticator, or Authy.</p>
@if (isMethodLocked('totp')) {
<span class="ac-tile__cta ac-tile__cta--locked"> <mat-icon>lock</mat-icon> Locked </span>
} @else {
<span class="ac-tile__cta">
{{ totp.verified ? 'Manage' : 'Set up' }} <mat-icon>arrow_forward</mat-icon>
</span>
}
<span class="ac-type-pill ac-tile__type">Type · TOTP</span>
</button>
<!-- Hardware -->
<button
class="ac-tile"
type="button"
[class.ac-tile--locked]="isMethodLocked('hardware')"
[class.ac-tile--selected]="isMethodPicked('hardware') || activeMethod === 'hardware'"
[class.ac-tile--active]="activeMethod === 'hardware'"
[disabled]="isMethodLocked('hardware')"
(click)="pickMethod('hardware')"
>
<div class="ac-tile__top">
<div class="ac-tile__icon ac-tile__icon--amber"><mat-icon>memory</mat-icon></div>
<span
class="ac-tile__radio"
[class.is-on]="isMethodPicked('hardware') || activeMethod === 'hardware'"
[class.is-locked]="isMethodLocked('hardware')"
[attr.aria-checked]="isMethodPicked('hardware') || activeMethod === 'hardware'"
role="radio"
>
@if (isMethodLocked('hardware')) {
<mat-icon>lock</mat-icon>
} @else if (isMethodPicked('hardware') || activeMethod === 'hardware') {
<mat-icon>check</mat-icon>
}
</span>
</div>
<h4 class="ac-tile__title">Hardware Token</h4>
<p class="ac-tile__desc">Smart Card, Digital Signature (DSC) or Biometric (FIDO2 / WebAuthn).</p>
@if (isMethodLocked('hardware')) {
<span class="ac-tile__cta ac-tile__cta--locked"> <mat-icon>lock</mat-icon> Locked </span>
} @else {
<span class="ac-tile__cta">Manage devices <mat-icon>arrow_forward</mat-icon></span>
}
<span class="ac-type-pill ac-tile__type">Type · TOKEN</span>
</button>
</div>
<div class="ac-picker-cta">
<div class="ac-picker-cta__hint">
@if (pickedMethod) {
<mat-icon>check_circle</mat-icon>
<span>
<strong>{{ methodLabel(pickedMethod) }}</strong> selected. Continue to set it up.
</span>
} @else {
<mat-icon>radio_button_unchecked</mat-icon>
<span>Pick a method above to continue.</span>
}
</div>
<button
mat-flat-button
color="primary"
class="ac-picker-cta__btn"
[disabled]="!pickedMethod"
(click)="continueWithPicked()"
>
@if (pickedMethod && pickedMethod === activeMethod) {
Manage {{ methodLabel(pickedMethod) }}
} @else if (pickedMethod) {
Set up {{ methodLabel(pickedMethod) }}
} @else {
Set up
}
<mat-icon>arrow_forward</mat-icon>
</button>
</div>
</section>
}
<!-- ════════════ SETUP PANEL (after selection) ════════════ -->
@if (selectedMethod) {
<section class="ac-panel">
<button type="button" class="ac-back" (click)="clearSelection()">
<span class="ac-back__icon"><mat-icon>arrow_back</mat-icon></span>
<span class="ac-back__text">
<span class="ac-back__sub">Authentication</span>
<span class="ac-back__main">All methods</span>
</span>
</button>
<!-- ───────── SMS OTP ───────── -->
@if (selectedMethod === 'sms') {
<header class="ac-panel__head">
<div class="ac-panel__icon ac-tile__icon--blue"><mat-icon>smartphone</mat-icon></div>
<div class="ac-panel__title">
<h3>SMS OTP</h3>
<span class="ac-type-pill">Type · SMS</span>
</div>
<span class="ac-state-pill" [class.ok]="sms.verified" [class.warn]="!sms.verified">
<mat-icon>{{ sms.verified ? 'check_circle' : 'cancel' }}</mat-icon>
{{ sms.verified ? 'Verified' : 'Not Verified' }}
</span>
</header>
<p class="ac-panel__sub">Verify a 10-digit Indian mobile number with a one-time passcode.</p>
<!-- Progress stepper -->
<ol class="ac-stepper">
<li
[class.is-active]="!sms.destinationConfirmed && !sms.verified"
[class.is-done]="sms.destinationConfirmed || sms.verified"
>
<span class="ac-stepper__num">1</span>
<div class="ac-stepper__text">
<strong>Enter mobile</strong>
<em>10-digit Indian number</em>
</div>
</li>
<li [class.is-active]="sms.destinationConfirmed && !sms.verified" [class.is-done]="sms.verified">
<span class="ac-stepper__num">2</span>
<div class="ac-stepper__text">
<strong>Verify OTP</strong>
<em>6-digit code</em>
</div>
</li>
<li [class.is-done]="sms.verified">
<span class="ac-stepper__num">3</span>
<div class="ac-stepper__text">
<strong>Activated</strong>
<em>Ready to use</em>
</div>
</li>
</ol>
<div class="ac-panel__body">
@if (sms.busy) {
<mat-progress-bar mode="indeterminate" class="ac-progress"></mat-progress-bar>
}
@if (!sms.destinationConfirmed) {
<div class="ac-otp-layout">
<div class="ac-otp-form">
<h5 class="ac-otp-form__title">Step 1 · Mobile number</h5>
<p class="ac-otp-form__hint">We'll send a 6-digit code to this number. Standard SMS rates may apply.</p>
@if (sms.destination && profileLoaded) {
<div class="ac-prefill-chip">
<mat-icon>person_pin</mat-icon>
Prefilled from your profile. Update your profile to change it.
</div>
}
<div class="ac-input-row">
<mat-form-field appearance="outline" class="ac-ff ac-ff--readonly">
<mat-label>Mobile number</mat-label>
<span matPrefix class="ac-prefix">+91&nbsp;</span>
<input matInput readonly [value]="sms.destination" placeholder="No mobile on profile" />
<mat-icon matSuffix class="ac-readonly-lock" matTooltip="From your profile">lock</mat-icon>
@if (sms.errorMsg) {
<mat-error>{{ sms.errorMsg }}</mat-error>
}
</mat-form-field>
<button
mat-flat-button
color="primary"
class="ac-input-row__btn"
[disabled]="sms.busy || !sms.destination"
(click)="sendOtp(sms)"
>
<mat-icon>send</mat-icon> {{ sms.busy ? 'Sending…' : 'Send OTP' }}
</button>
</div>
</div>
<aside class="ac-aside">
<div class="ac-aside__head"><mat-icon>info</mat-icon> What to expect</div>
<ul>
<li><mat-icon>schedule</mat-icon> OTP arrives within 30 seconds</li>
<li><mat-icon>verified</mat-icon> Code is valid for 5 minutes</li>
<li><mat-icon>autorenew</mat-icon> Resend available after 30 seconds</li>
<li><mat-icon>shield</mat-icon> Your number is encrypted at rest</li>
</ul>
</aside>
</div>
} @else if (!sms.verified) {
<div class="ac-otp-layout">
<div class="ac-otp-form">
<h5 class="ac-otp-form__title">Step 2 · Enter OTP</h5>
<div class="ac-otp-target">
<mat-icon>smartphone</mat-icon>
Code sent to <strong>+91 {{ sms.destination }}</strong>
</div>
<div class="ac-input-row">
<mat-form-field appearance="outline" class="ac-ff ac-ff--otp">
<mat-label>6-digit OTP</mat-label>
<input
id="ac-sms-otp-input"
matInput
[(ngModel)]="sms.otp"
maxlength="6"
inputmode="numeric"
placeholder="• • • • • •"
(input)="sms.errorMsg = ''"
(keypress)="$event.charCode >= 48 && $event.charCode <= 57 ? true : $event.preventDefault()"
/>
<mat-hint>
@if (sms.resendIn > 0) {
Resend in {{ sms.resendIn }}s
} @else {
<a class="ac-link" (click)="resendOtp(sms)">Resend OTP</a>
}
</mat-hint>
</mat-form-field>
<button
mat-flat-button
color="primary"
class="ac-input-row__btn"
[disabled]="sms.busy"
(click)="verifyOtp(sms)"
>
<mat-icon>verified</mat-icon> {{ sms.busy ? 'Verifying…' : 'Verify' }}
</button>
</div>
@if (sms.errorMsg) {
<div class="ac-inline-error">
<mat-icon>error_outline</mat-icon>
<span>{{ sms.errorMsg }}</span>
</div>
}
</div>
<aside class="ac-aside">
<div class="ac-aside__head"><mat-icon>help_outline</mat-icon> Didn't receive?</div>
<ul>
<li><mat-icon>signal_cellular_alt</mat-icon> Check signal & DND settings</li>
<li><mat-icon>autorenew</mat-icon> Tap "Resend OTP" after the timer</li>
<li><mat-icon>edit</mat-icon> Use "Change" to fix the number</li>
<li><mat-icon>support_agent</mat-icon> Contact support if it persists</li>
</ul>
</aside>
</div>
} @else {
<div class="ac-otp-done">
<mat-icon>check_circle</mat-icon>
<div>
<strong>SMS OTP active</strong>
<span
>OTP will be sent to <code>+91 {{ sms.destination }}</code> on every sign-in.</span
>
</div>
<button mat-stroked-button color="warn" (click)="disableOtpChannel(sms)">
<mat-icon>power_settings_new</mat-icon> Disable
</button>
</div>
}
</div>
}
<!-- ───────── Email OTP ───────── -->
@if (selectedMethod === 'email') {
<header class="ac-panel__head">
<div class="ac-panel__icon ac-tile__icon--cyan"><mat-icon>mail</mat-icon></div>
<div class="ac-panel__title">
<h3>Email OTP</h3>
<span class="ac-type-pill">Type · EMAIL</span>
</div>
<span class="ac-state-pill" [class.ok]="email.verified" [class.warn]="!email.verified">
<mat-icon>{{ email.verified ? 'check_circle' : 'cancel' }}</mat-icon>
{{ email.verified ? 'Verified' : 'Not Verified' }}
</span>
</header>
<p class="ac-panel__sub">Verify your email address with a one-time passcode.</p>
<!-- Progress stepper -->
<ol class="ac-stepper">
<li
[class.is-active]="!email.destinationConfirmed && !email.verified"
[class.is-done]="email.destinationConfirmed || email.verified"
>
<span class="ac-stepper__num">1</span>
<div class="ac-stepper__text">
<strong>Enter email</strong>
<em>Valid email address</em>
</div>
</li>
<li [class.is-active]="email.destinationConfirmed && !email.verified" [class.is-done]="email.verified">
<span class="ac-stepper__num">2</span>
<div class="ac-stepper__text">
<strong>Verify OTP</strong>
<em>6-digit code</em>
</div>
</li>
<li [class.is-done]="email.verified">
<span class="ac-stepper__num">3</span>
<div class="ac-stepper__text">
<strong>Activated</strong>
<em>Ready to use</em>
</div>
</li>
</ol>
<div class="ac-panel__body">
@if (email.busy) {
<mat-progress-bar mode="indeterminate" class="ac-progress"></mat-progress-bar>
}
@if (!email.destinationConfirmed) {
<div class="ac-otp-layout">
<div class="ac-otp-form">
<h5 class="ac-otp-form__title">Step 1 · Email address</h5>
<p class="ac-otp-form__hint">
We'll send a 6-digit code to this address. Check your spam folder if not received.
</p>
@if (email.destination && profileLoaded) {
<div class="ac-prefill-chip">
<mat-icon>person_pin</mat-icon>
Prefilled from your profile. Update your profile to change it.
</div>
}
<div class="ac-input-row">
<mat-form-field appearance="outline" class="ac-ff ac-ff--readonly">
<mat-label>Email address</mat-label>
<span matPrefix class="ac-prefix"><mat-icon>alternate_email</mat-icon></span>
<input
matInput
type="email"
readonly
[value]="email.destination"
placeholder="No email on profile"
/>
<mat-icon matSuffix class="ac-readonly-lock" matTooltip="From your profile">lock</mat-icon>
@if (email.errorMsg) {
<mat-error>{{ email.errorMsg }}</mat-error>
}
</mat-form-field>
<button
mat-flat-button
color="primary"
class="ac-input-row__btn"
[disabled]="email.busy || !email.destination"
(click)="sendOtp(email)"
>
<mat-icon>send</mat-icon> {{ email.busy ? 'Sending…' : 'Send OTP' }}
</button>
</div>
</div>
<aside class="ac-aside">
<div class="ac-aside__head"><mat-icon>info</mat-icon> What to expect</div>
<ul>
<li><mat-icon>schedule</mat-icon> Email arrives within 30 seconds</li>
<li><mat-icon>verified</mat-icon> Code is valid for 10 minutes</li>
<li><mat-icon>folder</mat-icon> Check spam / promotions folder</li>
<li><mat-icon>shield</mat-icon> Your address is encrypted at rest</li>
</ul>
</aside>
</div>
} @else if (!email.verified) {
<div class="ac-otp-layout">
<div class="ac-otp-form">
<h5 class="ac-otp-form__title">Step 2 · Enter OTP</h5>
<div class="ac-otp-target">
<mat-icon>mail</mat-icon>
Code sent to <strong>{{ email.destination }}</strong>
</div>
<div class="ac-input-row">
<mat-form-field appearance="outline" class="ac-ff ac-ff--otp">
<mat-label>6-digit OTP</mat-label>
<input
id="ac-email-otp-input"
matInput
[(ngModel)]="email.otp"
maxlength="6"
inputmode="numeric"
placeholder="• • • • • •"
(input)="email.errorMsg = ''"
(keypress)="$event.charCode >= 48 && $event.charCode <= 57 ? true : $event.preventDefault()"
/>
<mat-hint>
@if (email.resendIn > 0) {
Resend in {{ email.resendIn }}s
} @else {
<a class="ac-link" (click)="resendOtp(email)">Resend OTP</a>
}
</mat-hint>
</mat-form-field>
<button
mat-flat-button
color="primary"
class="ac-input-row__btn"
[disabled]="email.busy"
(click)="verifyOtp(email)"
>
<mat-icon>verified</mat-icon> {{ email.busy ? 'Verifying…' : 'Verify' }}
</button>
</div>
@if (email.errorMsg) {
<div class="ac-inline-error">
<mat-icon>error_outline</mat-icon>
<span>{{ email.errorMsg }}</span>
</div>
}
</div>
<aside class="ac-aside">
<div class="ac-aside__head"><mat-icon>help_outline</mat-icon> Didn't receive?</div>
<ul>
<li><mat-icon>folder</mat-icon> Check spam / promotions</li>
<li><mat-icon>autorenew</mat-icon> Tap "Resend OTP" after the timer</li>
<li><mat-icon>edit</mat-icon> Use "Change" to fix the address</li>
<li><mat-icon>support_agent</mat-icon> Contact support if it persists</li>
</ul>
</aside>
</div>
} @else {
<div class="ac-otp-done">
<mat-icon>check_circle</mat-icon>
<div>
<strong>Email OTP active</strong>
<span
>OTP will be sent to <code>{{ email.destination }}</code> on every sign-in.</span
>
</div>
<button mat-stroked-button color="warn" (click)="disableOtpChannel(email)">
<mat-icon>power_settings_new</mat-icon> Disable
</button>
</div>
}
</div>
}
<!-- ───────── TOTP ───────── -->
@if (selectedMethod === 'totp') {
<header class="ac-panel__head">
<div class="ac-panel__icon ac-tile__icon--violet"><mat-icon>qr_code_2</mat-icon></div>
<div class="ac-panel__title">
<h3>Authenticator App</h3>
<span class="ac-type-pill">Type · TOTP</span>
</div>
<span class="ac-state-pill" [class.ok]="totp.verified" [class.warn]="!totp.verified">
<mat-icon>{{ totp.verified ? 'check_circle' : 'cancel' }}</mat-icon>
{{ totp.verified ? 'Registered' : 'Not Registered' }}
</span>
</header>
<p class="ac-panel__sub">
Generate a key, scan it in your authenticator app, then enter the 6-digit code to register this device.
</p>
<div class="ac-panel__body">
@if (!totp.secret && !totp.verified) {
<div class="ac-totp-empty">
<mat-icon>qr_code_scanner</mat-icon>
<div>
<strong>No authenticator linked yet</strong>
<p>Generate a setup key, scan it in your TOTP app, then verify the 6-digit code shown there.</p>
</div>
<button mat-flat-button color="primary" (click)="generateTotpSecret()">
<mat-icon>auto_awesome</mat-icon> Generate setup key
</button>
</div>
} @else if (totp.secret && !totp.verified) {
<div class="ac-totp-setup">
<div class="ac-totp-setup__qr">
<div class="ac-qr-frame">
<div class="ac-qr-grid">
@for (row of totpQrCells; track $index) {
<div class="ac-qr-row">
@for (cell of row; track $index) {
<span class="ac-qr-cell" [class.is-on]="cell"></span>
}
</div>
}
</div>
</div>
<p class="ac-qr-caption">Scan in your authenticator app</p>
</div>
<div class="ac-totp-setup__details">
<div class="ac-totp-step-list">
<div class="ac-totp-step">
<span class="ac-totp-step__num">1</span>
<div>Open <strong>Google Authenticator</strong> (or any TOTP app) and tap “Add account”.</div>
</div>
<div class="ac-totp-step">
<span class="ac-totp-step__num">2</span>
<div>
Scan the code on the left, or enter the secret manually.
<div class="ac-totp-secret">
<code>{{ totp.secret }}</code>
<button mat-icon-button matTooltip="Copy" (click)="copyTotpSecret()">
<mat-icon>content_copy</mat-icon>
</button>
</div>
</div>
</div>
<div class="ac-totp-step">
<span class="ac-totp-step__num">3</span>
<div>Enter the 6-digit code shown by your app to verify and register this device.</div>
</div>
</div>
<mat-form-field appearance="outline" class="ac-ff ac-ff--otp">
<mat-label>Authenticator code</mat-label>
<input
matInput
[(ngModel)]="totp.otp"
maxlength="6"
inputmode="numeric"
placeholder="• • • • • •"
(input)="totp.errorMsg = ''"
(keypress)="$event.charCode >= 48 && $event.charCode <= 57 ? true : $event.preventDefault()"
/>
@if (totp.errorMsg) {
<mat-error>{{ totp.errorMsg }}</mat-error>
}
</mat-form-field>
<div class="ac-totp-actions">
<button mat-flat-button color="primary" [disabled]="totp.busy" (click)="verifyTotp()">
<mat-icon>verified</mat-icon> {{ totp.busy ? 'Verifying…' : 'Verify & Register' }}
</button>
<button mat-stroked-button color="warn" (click)="disableTotp()">
<mat-icon>refresh</mat-icon> Discard & restart
</button>
</div>
</div>
</div>
} @else {
<div class="ac-otp-done">
<mat-icon>check_circle</mat-icon>
<div>
<strong>Authenticator app registered</strong>
<span>Sign-in will require a 6-digit TOTP code from your app.</span>
</div>
<button mat-stroked-button color="warn" (click)="disableTotp()">
<mat-icon>power_settings_new</mat-icon> Unlink
</button>
</div>
}
</div>
}
<!-- ───────── Hardware Token (redesigned) ───────── -->
@if (selectedMethod === 'hardware') {
<header class="ac-panel__head">
<div class="ac-panel__icon ac-tile__icon--amber"><mat-icon>memory</mat-icon></div>
<div class="ac-panel__title">
<h3>Hardware Token</h3>
<span class="ac-type-pill">Type · TOKEN</span>
</div>
<span
class="ac-state-pill"
[class.ok]="methodStatus('hardware').ok"
[class.warn]="!methodStatus('hardware').ok"
>
<mat-icon>{{ methodStatus('hardware').ok ? 'check_circle' : 'cancel' }}</mat-icon>
{{ methodStatus('hardware').label }}
</span>
</header>
<p class="ac-panel__sub">Choose a device kind, then connect & register it.</p>
<div class="ac-panel__body">
@if (!selectedHwKind) {
<!-- Hardware sub-picker (compact rows with status, connect button) -->
<div class="ac-hw-list">
@for (item of hardware; track item.kind) {
<div class="ac-hw-row" [class.is-registered]="item.registered">
<div class="ac-hw-row__icon">
<mat-icon>{{ item.icon }}</mat-icon>
</div>
<div class="ac-hw-row__main">
<div class="ac-hw-row__title">
{{ item.title }}
<span class="ac-state-pill" [class.ok]="item.registered" [class.warn]="!item.registered">
<mat-icon>{{ item.registered ? 'check_circle' : 'radio_button_unchecked' }}</mat-icon>
{{ item.registered ? 'Registered' : 'Not Registered' }}
</span>
</div>
<p class="ac-hw-row__desc">{{ item.description }}</p>
@if (item.registered) {
<div class="ac-hw-row__meta">
<span
><mat-icon>tag</mat-icon> <code>{{ item.serial }}</code></span
>
<span><mat-icon>schedule</mat-icon> {{ item.registeredAt }}</span>
</div>
}
</div>
<div class="ac-hw-row__cta">
<button mat-flat-button color="primary" (click)="selectHwKind(item.kind)">
<mat-icon>{{ item.registered ? 'settings' : 'add' }}</mat-icon>
{{ item.registered ? 'Manage' : 'Register' }}
</button>
</div>
</div>
}
</div>
} @else if (currentHwItem) {
<!-- Hardware device registration view -->
<button type="button" class="ac-back ac-back--inner" (click)="clearHwKind()">
<span class="ac-back__icon"><mat-icon>arrow_back</mat-icon></span>
<span class="ac-back__text">
<span class="ac-back__sub">Hardware Token</span>
<span class="ac-back__main">All devices</span>
</span>
</button>
<div class="ac-hw-stage" [class.is-busy]="currentHwItem.busy" [class.is-done]="currentHwItem.registered">
<div class="ac-hw-device">
<div class="ac-hw-device__icon">
<mat-icon>{{ currentHwItem.icon }}</mat-icon>
@if (currentHwItem.busy) {
<span class="ac-hw-device__pulse"></span>
}
@if (currentHwItem.registered) {
<span class="ac-hw-device__check"><mat-icon>check</mat-icon></span>
}
</div>
<div class="ac-hw-device__connectors"><span></span><span></span><span></span></div>
</div>
<div class="ac-hw-info">
<h4>
{{ currentHwItem.title }}
<span
class="ac-state-pill"
[class.ok]="currentHwItem.registered"
[class.warn]="!currentHwItem.registered"
>
<mat-icon>{{ currentHwItem.registered ? 'check_circle' : 'cancel' }}</mat-icon>
{{ currentHwItem.registered ? 'Registered' : currentHwItem.busy ? 'Detecting…' : 'Not Registered' }}
</span>
</h4>
<p>{{ currentHwItem.description }}</p>
<ol class="ac-hw-steps">
<li [class.is-done]="currentHwItem.busy || currentHwItem.registered">
<mat-icon>{{
currentHwItem.busy || currentHwItem.registered ? 'check_circle' : 'circle'
}}</mat-icon>
Connect / insert the device
</li>
<li [class.is-done]="currentHwItem.registered" [class.is-active]="currentHwItem.busy">
<mat-icon>{{
currentHwItem.registered ? 'check_circle' : currentHwItem.busy ? 'autorenew' : 'circle'
}}</mat-icon>
Detect & read serial number
</li>
<li [class.is-done]="currentHwItem.registered">
<mat-icon>{{ currentHwItem.registered ? 'check_circle' : 'circle' }}</mat-icon>
Bind device to <strong>{{ username }}</strong>
</li>
</ol>
@if (currentHwItem.registered) {
<div class="ac-hw-info__detail">
<div>
<span class="ac-hw-info__label">Serial</span>
<code>{{ currentHwItem.serial }}</code>
</div>
<div>
<span class="ac-hw-info__label">Registered on</span>
<span>{{ currentHwItem.registeredAt }}</span>
</div>
</div>
}
<div class="ac-hw-info__actions">
@if (!currentHwItem.registered) {
<button
mat-flat-button
color="primary"
[disabled]="currentHwItem.busy"
(click)="registerHardware(currentHwItem)"
>
<mat-icon>{{ currentHwItem.busy ? 'autorenew' : 'usb' }}</mat-icon>
{{ currentHwItem.busy ? 'Detecting device…' : 'Connect & Register' }}
</button>
} @else {
<button mat-stroked-button color="warn" (click)="removeHardware(currentHwItem)">
<mat-icon>link_off</mat-icon> Unregister
</button>
}
</div>
</div>
</div>
}
</div>
}
</section>
}
</div>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,823 @@
/**
* 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, OnDestroy, OnInit, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatButton, MatIconButton } from '@angular/material/button';
import { MatCard, MatCardContent } from '@angular/material/card';
import { MatDialog } from '@angular/material/dialog';
import { MatFormField, MatLabel, MatPrefix, MatHint, MatError } from '@angular/material/form-field';
import { MatIcon } from '@angular/material/icon';
import { MatInput } from '@angular/material/input';
import { MatProgressBar } from '@angular/material/progress-bar';
import { DeleteDialogComponent } from 'app/shared/delete-dialog/delete-dialog.component';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { AuthenticationService } from 'app/core/authentication/authentication.service';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { UsersService } from 'app/users/users.service';
import { AccessControlService, MfaFactor } from './access-control.service';
type HwSubKind = 'smart-card' | 'digital-signature' | 'biometric';
type MethodKey = 'sms' | 'email' | 'totp' | 'hardware';
interface OtpChannelState {
/** Type code as the backend will store it (SMS or EMAIL) */
typeCode: 'SMS' | 'EMAIL';
destination: string;
destinationConfirmed: boolean;
otp: string;
otpSent: boolean;
resendIn: number;
verified: boolean;
errorMsg: string;
busy: boolean;
/** otpId returned from the OTP-generate endpoint, used by verify */
otpId: number | null;
/** verifiedOn timestamp from the server (ISO string) */
verifiedOn: string;
/** Persisted factor id from m_user_mfa_factor */
factorId: number | null;
}
interface TotpState {
typeCode: 'TOTP';
secret: string;
otp: string;
verified: boolean;
errorMsg: string;
busy: boolean;
}
interface HardwareItem {
kind: HwSubKind;
title: string;
description: string;
icon: string;
buttonLabel: string;
registered: boolean;
serial: string;
registeredAt: string;
busy: boolean;
}
interface PersistedPrefs {
sms?: { destination: string; verified: boolean };
email?: { destination: string; verified: boolean };
totp?: { secret: string; verified: boolean };
hardware?: Record<HwSubKind, { registered: boolean; serial: string; registeredAt: string }>;
}
@Component({
selector: 'mifosx-access-control',
templateUrl: './access-control.component.html',
styleUrls: ['./access-control.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FormsModule,
FaIconComponent,
MatCard,
MatCardContent,
MatIcon,
MatButton,
MatIconButton,
MatFormField,
MatLabel,
MatPrefix,
MatHint,
MatError,
MatInput,
MatProgressBar
]
})
export class AccessControlComponent implements OnInit, OnDestroy {
private authenticationService = inject(AuthenticationService);
private accessControlService = inject(AccessControlService);
private usersService = inject(UsersService);
private dialog = inject(MatDialog);
/** True once the user's profile (mobile/email) has been fetched & prefilled */
profileLoaded = false;
username = '';
private storageKey = '';
/** Currently selected method to configure (null = picker view) */
selectedMethod: MethodKey | null = null;
/** Transient picker selection (radio state) before user clicks Continue. */
pickedMethod: MethodKey | null = null;
/** Sub-selection inside the hardware token flow */
selectedHwKind: HwSubKind | null = null;
/** Section 1 — SMS / Email OTP */
sms: OtpChannelState = this.makeOtpState('SMS');
email: OtpChannelState = this.makeOtpState('EMAIL');
/** Section 2 — Authenticator app (TOTP) */
totp: TotpState = {
typeCode: 'TOTP',
secret: '',
otp: '',
verified: false,
errorMsg: '',
busy: false
};
/** Section 3 — Hardware Token (Smart Card / DSC / Biometric) */
hardware: HardwareItem[] = [
{
kind: 'smart-card',
title: 'Smart Card',
description: 'Register a contact / contactless smart card to authenticate sign-in.',
icon: 'credit_card',
buttonLabel: 'Register Smart Card',
registered: false,
serial: '',
registeredAt: '',
busy: false
},
{
kind: 'digital-signature',
title: 'Digital Signature (DSC)',
description: 'Register a Class-3 DSC USB token for high-value transaction signing.',
icon: 'verified_user',
buttonLabel: 'Register DSC Token',
registered: false,
serial: '',
registeredAt: '',
busy: false
},
{
kind: 'biometric',
title: 'Biometric',
description: 'Register fingerprint / face capture (FIDO2 / WebAuthn supported devices).',
icon: 'fingerprint',
buttonLabel: 'Register Biometric',
registered: false,
serial: '',
registeredAt: '',
busy: false
}
];
private resendTimer: any = null;
/**
* Flip to true once `GET /mfa/factors` is deployed on the backend.
* Until then, hitting it triggers Spring Security's Basic challenge,
* which makes the browser pop up its native sign-in dialog.
*/
private readonly mfaFactorsApiAvailable = true;
ngOnInit(): void {
const creds = this.authenticationService.getCredentials();
this.username = creds?.username || 'guest';
this.storageKey = `mifosx-mfa-prefs:${this.username}`;
this.loadPrefs();
this.prefillFromProfile(creds);
if (this.mfaFactorsApiAvailable) {
this.hydrateFromServer();
}
// If a method is already active, pre-select its radio so the page loads
// with the user's current choice highlighted in green.
if (this.activeMethod) {
this.pickedMethod = this.activeMethod;
}
}
/**
* Seed SMS / Email destination fields from the signed-in user's profile.
* Prefer values cached on the credentials object (populated at login by
* `POST /authentication`); fall back to `GET /users/{id}` only when the
* auth response didn't include them.
*/
private prefillFromProfile(creds: any): void {
// Try the credentials object first (works if /authentication returns these)
const sessionMobile = this.pickMobile(creds);
const sessionEmail = this.pickEmail(creds);
if (sessionMobile || sessionEmail) {
this.applyContact(sessionMobile, sessionEmail);
// If only one of the two came from the session, still fetch the profile
// for the missing piece.
if (sessionMobile && sessionEmail) {
this.profileLoaded = true;
return;
}
}
if (creds?.userId == null) {
this.profileLoaded = true;
return;
}
this.usersService.getUser(String(creds.userId)).subscribe({
next: (user: any) => {
this.profileLoaded = true;
const apiMobile = this.pickMobile(user);
const apiEmail = this.pickEmail(user);
this.applyContact(apiMobile, apiEmail);
},
error: () => {
this.profileLoaded = true;
}
});
}
/** Look across the common field names Fineract returns for mobile. */
private pickMobile(src: any): string | undefined {
if (!src) return undefined;
return (
src.mobileNo ||
src.mobile_no ||
src.mobileNumber ||
src.mobile ||
src.phoneNumber ||
src.phone ||
src.contactNumber ||
src.user?.mobileNo ||
src.user?.mobile_no ||
undefined
);
}
/** Look across the common field names Fineract returns for email. */
private pickEmail(src: any): string | undefined {
if (!src) return undefined;
return src.email || src.emailAddress || src.email_address || src.user?.email || undefined;
}
/**
* Apply mobile/email to the channel state, overwriting any previous value.
* The profile is the source of truth — we never preserve a stale local
* cache once a fresh value comes back.
*/
private applyContact(mobileNo?: string, email?: string): boolean {
let applied = false;
if (mobileNo) {
// Strip all non-digits then keep the last 10 digits (handles "+91 98…", "0 98…", etc.)
const digits = String(mobileNo).replace(/\D/g, '');
this.sms.destination = digits.length >= 10 ? digits.slice(-10) : digits;
applied = true;
}
if (email) {
this.email.destination = String(email).trim();
applied = true;
}
return applied;
}
/**
* Pull persisted MFA factors from the backend so the UI shows the
* actual verified state after a page reload. The API response REPLACES
* any previous in-memory state, so a row deleted in the DB clears the
* UI on the very next load.
*/
private hydrateFromServer(): void {
this.accessControlService.listMyFactors().subscribe({
next: (factors: MfaFactor[]) => {
// Clear all factors first — server is authoritative.
this.resetAllFactors();
if (!factors?.length) return;
for (const f of factors) {
if (f.factorType === 'SMS') {
this.applyFactor(this.sms, f);
} else if (f.factorType === 'EMAIL') {
this.applyFactor(this.email, f);
} else if (f.factorType === 'TOTP' && f.verified) {
this.totp.secret = f.secret || this.totp.secret;
this.totp.verified = true;
} else if (f.factorType === 'TOKEN') {
const item = this.hardware.find((h) => h.serial === f.serial);
if (item) {
item.registered = true;
item.serial = f.serial || item.serial;
item.registeredAt = f.verifiedOn || item.registeredAt;
}
}
}
// Re-pick the active method for the radio after hydrate.
if (this.activeMethod) {
this.pickedMethod = this.activeMethod;
} else {
this.pickedMethod = null;
}
},
error: () => {
/* endpoint may not exist yet — local state is left empty */
}
});
}
/** Wipe every factor's verified / registered flags before re-applying API truth. */
private resetAllFactors(): void {
this.sms.verified = false;
this.sms.destinationConfirmed = false;
this.sms.factorId = null;
this.sms.verifiedOn = '';
this.email.verified = false;
this.email.destinationConfirmed = false;
this.email.factorId = null;
this.email.verifiedOn = '';
this.totp.verified = false;
this.totp.secret = '';
for (const h of this.hardware) {
h.registered = false;
h.serial = '';
h.registeredAt = '';
}
}
private applyFactor(channel: OtpChannelState, f: MfaFactor): void {
if (f.destination) channel.destination = f.destination;
channel.verified = !!f.verified;
channel.destinationConfirmed = !!f.verified;
channel.factorId = f.id ?? null;
channel.verifiedOn = f.verifiedOn || '';
}
ngOnDestroy(): void {
if (this.resendTimer) clearInterval(this.resendTimer);
}
// ───────────────────────────── SMS / Email OTP
/** Send OTP via /login/otp/generate (SMS) or /login/otp/email/generate */
sendOtp(channel: OtpChannelState): void {
const dest = channel.destination?.trim() || '';
if (!this.isDestinationValid(channel.typeCode, dest)) {
channel.errorMsg =
channel.typeCode === 'SMS' ? 'Enter a valid 10-digit mobile number.' : 'Enter a valid email address.';
return;
}
if (!this.username) {
channel.errorMsg = 'You must be signed in to set up MFA.';
return;
}
channel.errorMsg = '';
channel.busy = true;
const obs$ =
channel.typeCode === 'SMS'
? this.accessControlService.sendMobileOtp(this.username)
: this.accessControlService.sendEmailOtp(this.username);
obs$.subscribe({
next: (res) => {
channel.busy = false;
if (res?.success && res.otpId != null) {
channel.otpId = res.otpId;
channel.otpSent = true;
channel.destinationConfirmed = true;
channel.otp = '';
channel.resendIn = 30;
this.startResendTimer(channel);
// Move the OTP entry into view + focus the input so the user
// doesn't have to scroll down to type the code.
setTimeout(() => this.focusOtpInput(channel.typeCode), 100);
} else {
channel.errorMsg = res?.message || 'Could not send OTP. Try again.';
}
},
error: (err) => {
channel.busy = false;
channel.errorMsg = err?.error?.message || err?.error?.defaultUserMessage || 'OTP request failed.';
}
});
}
/** Scroll-to + focus the OTP entry field for the active channel. */
private focusOtpInput(typeCode: 'SMS' | 'EMAIL'): void {
const id = typeCode === 'SMS' ? 'ac-sms-otp-input' : 'ac-email-otp-input';
const el = document.getElementById(id) as HTMLInputElement | null;
if (!el) return;
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
el.focus({ preventScroll: true });
}
resendOtp(channel: OtpChannelState): void {
if (channel.resendIn > 0) return;
this.sendOtp(channel);
}
/** Verify OTP and persist the factor in m_user_mfa_factor */
verifyOtp(channel: OtpChannelState): void {
const code = (channel.otp || '').trim();
if (!/^\d{6}$/.test(code)) {
channel.errorMsg = 'Enter the 6-digit code received.';
return;
}
if (channel.otpId == null) {
channel.errorMsg = 'OTP session missing — request a new code.';
return;
}
channel.errorMsg = '';
channel.busy = true;
const verify$ =
channel.typeCode === 'SMS'
? this.accessControlService.verifyMobileOtp(channel.otpId, code)
: this.accessControlService.verifyEmailOtp(channel.otpId, code);
verify$.subscribe({
next: (res) => {
if (!res?.verified) {
channel.busy = false;
channel.errorMsg = res?.message || 'Invalid OTP.';
return;
}
const finishVerified = (saved?: MfaFactor) => {
channel.busy = false;
channel.verified = true;
channel.otpSent = false;
channel.resendIn = 0;
channel.factorId = saved?.id ?? null;
channel.verifiedOn = saved?.verifiedOn || new Date().toISOString();
if (this.resendTimer) clearInterval(this.resendTimer);
this.savePrefs();
};
if (!this.mfaFactorsApiAvailable) {
finishVerified();
return;
}
// Persist the verified factor — sets verified_on in the DB
this.accessControlService
.saveFactor({
factorType: channel.typeCode,
destination: channel.destination,
otpId: channel.otpId!
})
.subscribe({
next: (saved) => finishVerified(saved),
error: () => finishVerified() // OTP verified upstream, persistence failed — keep UX
});
},
error: (err) => {
channel.busy = false;
channel.errorMsg = err?.error?.message || err?.error?.defaultUserMessage || 'Verification failed.';
}
});
}
changeDestination(channel: OtpChannelState): void {
channel.destinationConfirmed = false;
channel.otpSent = false;
channel.otp = '';
channel.errorMsg = '';
channel.resendIn = 0;
channel.otpId = null;
}
disableOtpChannel(channel: OtpChannelState): void {
const label = channel.typeCode === 'SMS' ? 'SMS OTP' : 'Email OTP';
this.dialog
.open(DeleteDialogComponent, {
data: { deleteContext: `${label} as your MFA method` }
})
.afterClosed()
.subscribe((response: any) => {
if (!response?.delete) return;
const finish = () => {
channel.verified = false;
channel.otp = '';
channel.otpSent = false;
channel.destinationConfirmed = false;
channel.errorMsg = '';
channel.resendIn = 0;
channel.otpId = null;
channel.factorId = null;
channel.verifiedOn = '';
this.savePrefs();
};
if (this.mfaFactorsApiAvailable && channel.factorId != null) {
this.accessControlService.disableFactor(channel.factorId).subscribe({
next: () => finish(),
error: () => finish()
});
} else {
finish();
}
});
}
private isDestinationValid(type: 'SMS' | 'EMAIL', value: string): boolean {
if (!value) return false;
if (type === 'SMS') return /^\d{10}$/.test(value);
return /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(value);
}
private startResendTimer(channel: OtpChannelState): void {
if (this.resendTimer) clearInterval(this.resendTimer);
this.resendTimer = setInterval(() => {
if (channel.resendIn > 0) channel.resendIn--;
if (channel.resendIn <= 0 && this.resendTimer) {
clearInterval(this.resendTimer);
this.resendTimer = null;
}
}, 1000);
}
// ───────────────────────────── Authenticator app (TOTP)
generateTotpSecret(): void {
this.totp.secret = this.randomBase32(16);
this.totp.otp = '';
this.totp.verified = false;
this.totp.errorMsg = '';
}
/** otpauth URI shown for manual entry / QR encoding by the user's authenticator app */
get totpOtpauthUri(): string {
if (!this.totp.secret) return '';
const issuer = 'CBSNexora';
const label = `${issuer}:${this.username}`;
return `otpauth://totp/${encodeURIComponent(label)}?secret=${this.totp.secret}&issuer=${encodeURIComponent(issuer)}`;
}
/** Visual placeholder QR — deterministic 8x8 grid from the secret string */
get totpQrCells(): boolean[][] {
if (!this.totp.secret) return [];
const grid: boolean[][] = [];
let seed = 0;
for (let i = 0; i < this.totp.secret.length; i++) {
seed = (seed * 31 + this.totp.secret.charCodeAt(i)) & 0xffffffff;
}
for (let r = 0; r < 8; r++) {
const row: boolean[] = [];
for (let c = 0; c < 8; c++) {
// Three corner finder squares
const corner =
(r < 3 && c < 3) || (r < 3 && c > 4) || (r > 4 && c < 3)
? r === 0 || r === 2 || r === 5 || r === 7 || c === 0 || c === 2 || c === 5 || c === 7
: null;
if (corner !== null) {
row.push(corner);
} else {
seed = (seed * 1103515245 + 12345) & 0x7fffffff;
row.push((seed & 1) === 1);
}
}
grid.push(row);
}
return grid;
}
copyTotpSecret(): void {
if (!this.totp.secret) return;
try {
navigator.clipboard?.writeText(this.totp.secret);
} catch {
/* clipboard not available */
}
}
verifyTotp(): void {
const code = (this.totp.otp || '').trim();
if (!/^\d{6}$/.test(code)) {
this.totp.errorMsg = 'Enter the 6-digit code from your authenticator app.';
return;
}
this.totp.errorMsg = '';
this.totp.busy = true;
setTimeout(() => {
this.totp.busy = false;
this.totp.verified = true;
this.savePrefs();
}, 500);
}
disableTotp(): void {
this.totp = { typeCode: 'TOTP', secret: '', otp: '', verified: false, errorMsg: '', busy: false };
this.savePrefs();
}
private randomBase32(len: number): string {
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
let out = '';
for (let i = 0; i < len; i++) {
out += alphabet.charAt(Math.floor(Math.random() * alphabet.length));
}
return out;
}
// ───────────────────────────── Hardware Token
/** Mock device handshake — generates a synthetic serial and marks the item registered */
registerHardware(item: HardwareItem): void {
item.busy = true;
setTimeout(() => {
item.busy = false;
item.registered = true;
item.serial = this.randomSerial(item.kind);
item.registeredAt = new Date().toLocaleString();
this.savePrefs();
}, 700);
}
removeHardware(item: HardwareItem): void {
item.registered = false;
item.serial = '';
item.registeredAt = '';
this.savePrefs();
}
private randomSerial(kind: HwSubKind): string {
const prefix = kind === 'smart-card' ? 'SC' : kind === 'digital-signature' ? 'DSC' : 'BIO';
let body = '';
for (let i = 0; i < 10; i++) body += Math.floor(Math.random() * 10).toString();
return `${prefix}-${body}`;
}
// ───────────────────────────── Aggregate state
/** True when at least one method (any type) is verified/registered */
get anyMethodActive(): boolean {
return this.sms.verified || this.email.verified || this.totp.verified || this.hardware.some((h) => h.registered);
}
/** Returns the single currently-active MFA method, or null if none. */
get activeMethod(): MethodKey | null {
if (this.sms.verified) return 'sms';
if (this.email.verified) return 'email';
if (this.totp.verified) return 'totp';
if (this.hardware.some((h) => h.registered)) return 'hardware';
return null;
}
/** A method tile is locked when ANOTHER method is already active. */
isMethodLocked(method: MethodKey): boolean {
const active = this.activeMethod;
return active !== null && active !== method;
}
// ───────────────────────────── Selection navigation
/** First step: just mark the radio as picked without leaving the picker. */
pickMethod(method: MethodKey): void {
if (this.isMethodLocked(method)) return;
this.pickedMethod = method;
}
/** True when the picker has a current radio pick. */
isMethodPicked(method: MethodKey): boolean {
return this.pickedMethod === method;
}
/** Second step: enter the setup panel for the picked / requested method. */
selectMethod(method: MethodKey): void {
if (this.isMethodLocked(method)) return;
this.pickedMethod = method;
this.selectedMethod = method;
if (method !== 'hardware') this.selectedHwKind = null;
}
/** Continue button — opens setup for the currently picked method. */
continueWithPicked(): void {
if (!this.pickedMethod) return;
this.selectMethod(this.pickedMethod);
}
/** Friendly label for the picked-method title (used in the Continue button). */
methodLabel(method: MethodKey): string {
switch (method) {
case 'sms':
return 'SMS OTP';
case 'email':
return 'Email OTP';
case 'totp':
return 'Authenticator App';
case 'hardware':
return 'Hardware Token';
}
}
clearSelection(): void {
this.selectedMethod = null;
this.pickedMethod = null;
this.selectedHwKind = null;
}
selectHwKind(kind: HwSubKind): void {
this.selectedHwKind = kind;
}
clearHwKind(): void {
this.selectedHwKind = null;
}
/** Convenience: status text for a method tile */
methodStatus(method: MethodKey): { label: string; ok: boolean } {
switch (method) {
case 'sms':
return { label: this.sms.verified ? 'Verified' : 'Not configured', ok: this.sms.verified };
case 'email':
return { label: this.email.verified ? 'Verified' : 'Not configured', ok: this.email.verified };
case 'totp':
return { label: this.totp.verified ? 'Registered' : 'Not configured', ok: this.totp.verified };
case 'hardware': {
const count = this.hardware.filter((h) => h.registered).length;
return { label: count > 0 ? `${count} device${count === 1 ? '' : 's'}` : 'Not configured', ok: count > 0 };
}
}
}
/** The active hardware sub-item (used when configuring) */
get currentHwItem(): HardwareItem | null {
if (!this.selectedHwKind) return null;
return this.hardware.find((h) => h.kind === this.selectedHwKind) || null;
}
// ───────────────────────────── Persistence (localStorage stub)
private makeOtpState(typeCode: 'SMS' | 'EMAIL'): OtpChannelState {
return {
typeCode,
destination: '',
destinationConfirmed: false,
otp: '',
otpSent: false,
resendIn: 0,
verified: false,
errorMsg: '',
busy: false,
otpId: null,
verifiedOn: '',
factorId: null
};
}
private loadPrefs(): void {
// When the backend MFA-factor API is wired up, it is the SOLE source of
// truth for verified state — never restore "verified" / "registered"
// from a local cache, otherwise stale entries survive DB cleanup or
// re-logins. We only keep the local cache as a last-resort fallback
// for offline / API-unavailable scenarios.
if (this.mfaFactorsApiAvailable) {
try {
window.localStorage.removeItem(this.storageKey);
} catch {
/* ignore */
}
return;
}
try {
const raw = window.localStorage.getItem(this.storageKey);
if (!raw) return;
const parsed = JSON.parse(raw) as PersistedPrefs;
if (parsed.sms) {
this.sms.verified = !!parsed.sms.verified;
this.sms.destinationConfirmed = this.sms.verified;
}
if (parsed.email) {
this.email.verified = !!parsed.email.verified;
this.email.destinationConfirmed = this.email.verified;
}
if (parsed.totp) {
this.totp.secret = parsed.totp.secret || '';
this.totp.verified = !!parsed.totp.verified;
}
if (parsed.hardware) {
for (const h of this.hardware) {
const stored = parsed.hardware[h.kind];
if (stored) {
h.registered = !!stored.registered;
h.serial = stored.serial || '';
h.registeredAt = stored.registeredAt || '';
}
}
}
} catch {
/* ignore */
}
}
private savePrefs(): void {
// When the backend MFA-factor API is the source of truth, we don't
// mirror it to localStorage at all — every page load re-hydrates from
// /mfa/factors so a deleted DB row cannot resurrect from cache.
if (this.mfaFactorsApiAvailable) {
try {
window.localStorage.removeItem(this.storageKey);
} catch {
/* ignore */
}
return;
}
const payload: PersistedPrefs = {
sms: { destination: this.sms.destination, verified: this.sms.verified },
email: { destination: this.email.destination, verified: this.email.verified },
totp: { secret: this.totp.secret, verified: this.totp.verified },
hardware: this.hardware.reduce(
(acc, h) => {
acc[h.kind] = { registered: h.registered, serial: h.serial, registeredAt: h.registeredAt };
return acc;
},
{} as PersistedPrefs['hardware']
)
};
try {
window.localStorage.setItem(this.storageKey, JSON.stringify(payload));
} catch {
/* ignore */
}
}
}
@@ -0,0 +1,22 @@
/**
* 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 { AccessControlRoutingModule } from './access-control-routing.module';
import { AccessControlComponent } from './access-control.component';
@NgModule({
imports: [
SharedModule,
AccessControlRoutingModule,
AccessControlComponent
]
})
export class AccessControlModule {}
@@ -0,0 +1,97 @@
/**
* 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, HttpHeaders } from '@angular/common/http';
import { Observable } from 'rxjs';
export interface OtpGenerateResponse {
success: boolean;
otpId?: number;
otpReference?: string;
message?: string;
}
export interface OtpVerifyResponse {
verified: boolean;
message?: string;
}
export type MfaFactorType = 'SMS' | 'EMAIL' | 'TOTP' | 'TOKEN';
export interface MfaFactor {
id?: number;
factorType: MfaFactorType;
destination?: string;
secret?: string;
serial?: string;
verified: boolean;
verifiedOn?: string;
otpLogId?: number;
}
/**
* Wraps the login-OTP endpoints (LincPay SMS + SMTP email) plus the
* MFA-factor persistence endpoints used by Access Control.
*/
@Injectable({ providedIn: 'root' })
export class AccessControlService {
private http = inject(HttpClient);
// ───── Mobile OTP (LincPay) ─────
sendMobileOtp(username: string): Observable<OtpGenerateResponse> {
return this.http.post<OtpGenerateResponse>('/login/otp/generate', { username });
}
verifyMobileOtp(otpId: number, otp: string): Observable<OtpVerifyResponse> {
return this.http.post<OtpVerifyResponse>('/login/otp/verify', { otpId, otp });
}
// ───── Email OTP (SMTP) ─────
sendEmailOtp(username: string): Observable<OtpGenerateResponse> {
return this.http.post<OtpGenerateResponse>('/login/otp/email/generate', { username });
}
verifyEmailOtp(otpId: number, otp: string): Observable<OtpVerifyResponse> {
return this.http.post<OtpVerifyResponse>('/login/otp/email/verify', { otpId, otp });
}
// ───── MFA factors persistence (NEW backend endpoints) ─────
/**
* Marker header that tells our Fineract security filter to return a plain
* 401 (no `WWW-Authenticate: Basic`) so the browser doesn't pop up its
* native Basic-Auth dialog when the endpoint isn't wired up yet.
*/
private silentHeaders = new HttpHeaders({ 'X-Requested-With': 'XMLHttpRequest' });
/** List the signed-in user's MFA factors. */
listMyFactors(): Observable<MfaFactor[]> {
return this.http.get<MfaFactor[]>('/mfa/factors', { headers: this.silentHeaders });
}
/**
* Persist a verified factor. Backend should set `verified=true`,
* `verified_on=NOW()`, and link to the matching `m_otp_log` row.
*/
saveFactor(payload: {
factorType: MfaFactorType;
destination?: string;
secret?: string;
serial?: string;
otpId?: number;
}): Observable<MfaFactor> {
return this.http.post<MfaFactor>('/mfa/factors', payload, { headers: this.silentHeaders });
}
/** Disable a factor (logical delete — sets `disabled_on`). */
disableFactor(factorId: number): Observable<{ resourceId: number }> {
return this.http.delete<{ resourceId: number }>(`/mfa/factors/${factorId}`, {
headers: this.silentHeaders
});
}
}
+4
View File
@@ -46,6 +46,8 @@ import { SearchModule } from './search/search.module';
import { NotificationsModule } from './notifications/notifications.module'; import { NotificationsModule } from './notifications/notifications.module';
import { CollectionsModule } from './collections/collections.module'; import { CollectionsModule } from './collections/collections.module';
import { ProfileModule } from './profile/profile.module'; import { ProfileModule } from './profile/profile.module';
import { AccessControlModule } from './access-control/access-control.module';
import { OtpAuditLogsModule } from './otp-audit-logs/otp-audit-logs.module';
import { TasksModule } from './tasks/tasks.module'; import { TasksModule } from './tasks/tasks.module';
import { ConfigurationWizardModule } from './configuration-wizard/configuration-wizard.module'; import { ConfigurationWizardModule } from './configuration-wizard/configuration-wizard.module';
import { RemittancesModule } from './remittances/remittances.module'; import { RemittancesModule } from './remittances/remittances.module';
@@ -126,6 +128,8 @@ export function HttpLoaderFactory(http: HttpClient) {
HomeModule, HomeModule,
LoginModule, LoginModule,
ProfileModule, ProfileModule,
AccessControlModule,
OtpAuditLogsModule,
SettingsModule, SettingsModule,
NavigationModule, NavigationModule,
ClientsModule, ClientsModule,
@@ -216,7 +216,7 @@ export class ClientFamilyMemberDialogComponent implements OnInit {
} }
this.searching = true; this.searching = true;
this.searchTimer = setTimeout(() => { this.searchTimer = setTimeout(() => {
this.clientsService.searchByText(text, 0, 8).subscribe({ this.clientsService.searchByText(text, 0, 8, '', '', true).subscribe({
next: (data: any) => { next: (data: any) => {
this.searchResults = data?.content || []; this.searchResults = data?.content || [];
this.searching = false; this.searching = false;
@@ -161,8 +161,13 @@ export class ClientFamilyMembersStepComponent implements OnInit {
panelClass: 'fm-dialog-panel' panelClass: 'fm-dialog-panel'
}); });
ref.afterClosed().subscribe((response: any) => { ref.afterClosed().subscribe((response: any) => {
if (response?.member) { if (!response?.member) return;
this.clientFamilyMembers.splice(index, 1, response.member); const updated = response.member;
this.clientFamilyMembers.splice(index, 1, updated);
// Persist to backend if this is an existing (already-saved) member
if (member.id && this.clientId) {
const { id, _linkedClient, _marriageCertFile, ...payload } = updated;
this.clientsService.editFamilyMember(this.clientId, member.id, payload).subscribe({ error: () => {} });
} }
}); });
} }
@@ -172,7 +177,15 @@ export class ClientFamilyMembersStepComponent implements OnInit {
data: { deleteContext: `${name}` } data: { deleteContext: `${name}` }
}); });
ref.afterClosed().subscribe((response: any) => { ref.afterClosed().subscribe((response: any) => {
if (response?.delete) { if (!response?.delete) return;
const member = this.clientFamilyMembers[index];
// Persist deletion to backend for existing members; locally-added ones just drop from the array
if (member?.id && this.clientId) {
this.clientsService.deleteFamilyMember(this.clientId, member.id.toString()).subscribe({
next: () => this.clientFamilyMembers.splice(index, 1),
error: () => this.clientFamilyMembers.splice(index, 1)
});
} else {
this.clientFamilyMembers.splice(index, 1); this.clientFamilyMembers.splice(index, 1);
} }
}); });
@@ -228,7 +241,7 @@ export class ClientFamilyMembersStepComponent implements OnInit {
} }
this.nomineeSearching = true; this.nomineeSearching = true;
this.nomineeSearchTimer = setTimeout(() => { this.nomineeSearchTimer = setTimeout(() => {
this.clientsService.searchByText(text, 0, 10).subscribe({ this.clientsService.searchByText(text, 0, 10, '', '', true).subscribe({
next: (data: any) => { next: (data: any) => {
this.nomineeSearchResults = data?.content || []; this.nomineeSearchResults = data?.content || [];
this.nomineeSearching = false; this.nomineeSearching = false;
@@ -324,7 +337,7 @@ export class ClientFamilyMembersStepComponent implements OnInit {
} }
this.guardianSearching = true; this.guardianSearching = true;
this.guardianSearchTimer = setTimeout(() => { this.guardianSearchTimer = setTimeout(() => {
this.clientsService.searchByText(text, 0, 10).subscribe({ this.clientsService.searchByText(text, 0, 10, '', '', true).subscribe({
next: (data: any) => { next: (data: any) => {
this.guardianSearchResults = data?.content || []; this.guardianSearchResults = data?.content || [];
this.guardianSearching = false; this.guardianSearching = false;
@@ -27,7 +27,7 @@ import {
UntypedFormControl, UntypedFormControl,
ReactiveFormsModule ReactiveFormsModule
} from '@angular/forms'; } from '@angular/forms';
import { Subject } from 'rxjs'; import { Subject, Subscription, merge } from 'rxjs';
import { filter, switchMap, takeUntil } from 'rxjs/operators'; import { filter, switchMap, takeUntil } from 'rxjs/operators';
import { ClientsService } from 'app/clients/clients.service'; import { ClientsService } from 'app/clients/clients.service';
import { Dates } from 'app/core/utils/dates'; import { Dates } from 'app/core/utils/dates';
@@ -340,6 +340,8 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
sanctionsBlocked = false; sanctionsBlocked = false;
/** Screening log ID from sanctions API */ /** Screening log ID from sanctions API */
screeningLogId: number | null = null; screeningLogId: number | null = null;
/** Snapshot of the name that was last verified — used to invalidate verification when name changes */
private verifiedName: string = '';
/** /**
* @param {FormBuilder} formBuilder Form Builder * @param {FormBuilder} formBuilder Form Builder
@@ -500,6 +502,7 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
} }
if (data.nameVerified === true || data.nameVerified === 'true' || data.nameVerified === 1) { if (data.nameVerified === true || data.nameVerified === 'true' || data.nameVerified === 1) {
this.sanctionsVerified = true; this.sanctionsVerified = true;
this.verifiedName = this.currentNameSnapshot();
} else if (data.nameVerified === false || data.nameVerified === 'false' || data.nameVerified === 0) { } else if (data.nameVerified === false || data.nameVerified === 'false' || data.nameVerified === 0) {
this.sanctionsVerified = false; this.sanctionsVerified = false;
} }
@@ -701,6 +704,8 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
}) })
); );
} }
// Re-attach name-change watchers after controls are (re)created
setTimeout(() => this.watchNameChangesForReverification());
}); });
// Initialize legal form — use client's existing value in edit mode, default to PERSON for create // Initialize legal form — use client's existing value in edit mode, default to PERSON for create
const initialLegalForm = this.clientData?.legalForm?.id || LegalFormId.PERSON; const initialLegalForm = this.clientData?.legalForm?.id || LegalFormId.PERSON;
@@ -901,20 +906,7 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
* If a match is found, sanctionsBlocked is set to true blocking further steps. * If a match is found, sanctionsBlocked is set to true blocking further steps.
*/ */
verifySanctions() { verifySanctions() {
const firstname = this.createClientForm.get('firstname')?.value || ''; const name = this.currentNameSnapshot();
const middlename = this.createClientForm.get('middlename')?.value || '';
const lastname = this.createClientForm.get('lastname')?.value || '';
const fullname = this.createClientForm.get('fullname')?.value || '';
const name =
fullname ||
[
firstname,
middlename,
lastname
]
.filter(Boolean)
.join(' ');
const dialogRef = this.dialog.open(SanctionsVerifyDialogComponent, { const dialogRef = this.dialog.open(SanctionsVerifyDialogComponent, {
width: '550px', width: '550px',
@@ -927,14 +919,62 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
// CLEAR — no match found // CLEAR — no match found
this.sanctionsVerified = true; this.sanctionsVerified = true;
this.sanctionsBlocked = false; this.sanctionsBlocked = false;
this.verifiedName = name;
} else if (result.verified === false) { } else if (result.verified === false) {
// FLAGGED — match found, block account opening // FLAGGED — match found, block account opening
this.sanctionsVerified = false; this.sanctionsVerified = false;
this.sanctionsBlocked = true; this.sanctionsBlocked = true;
this.verifiedName = '';
} }
}); });
} }
/** Concatenated current name from the form (handles individual + entity) */
private currentNameSnapshot(): string {
const firstname = this.createClientForm.get('firstname')?.value || '';
const middlename = this.createClientForm.get('middlename')?.value || '';
const lastname = this.createClientForm.get('lastname')?.value || '';
const fullname = this.createClientForm.get('fullname')?.value || '';
return (
fullname ||
[
firstname,
middlename,
lastname
]
.filter(Boolean)
.join(' ')
).trim();
}
/**
* Watch name fields — once a name has been verified, any edit invalidates the
* sanctions verification badge so the user must re-verify before submitting.
*/
private nameWatchSub: Subscription | null = null;
private watchNameChangesForReverification() {
this.nameWatchSub?.unsubscribe();
const fields = [
'firstname',
'middlename',
'lastname',
'fullname'
];
const controls = fields.map((f) => this.createClientForm.get(f)).filter((c) => !!c);
if (!controls.length) return;
this.nameWatchSub = merge(...controls.map((c) => c!.valueChanges))
.pipe(takeUntil(this.destroy$))
.subscribe(() => {
// Only invalidate if a verification was previously recorded
if (!this.sanctionsVerified && !this.sanctionsBlocked) return;
if (this.currentNameSnapshot() !== this.verifiedName) {
this.sanctionsVerified = false;
this.sanctionsBlocked = false;
this.screeningLogId = null;
}
});
}
/** Search existing clients to select as guardian */ /** Search existing clients to select as guardian */
onGuardianSearch(text: string): void { onGuardianSearch(text: string): void {
this.guardianSearchText = text; this.guardianSearchText = text;
@@ -947,7 +987,7 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
this.guardianSearching = true; this.guardianSearching = true;
this.guardianSearchTimer = setTimeout(() => { this.guardianSearchTimer = setTimeout(() => {
this.clientService.searchByText(text, 0, 10).subscribe({ this.clientService.searchByText(text, 0, 10, '', '', true).subscribe({
next: (data: any) => { next: (data: any) => {
this.guardianSearchResults = data?.content || []; this.guardianSearchResults = data?.content || [];
this.guardianSearching = false; this.guardianSearching = false;
@@ -1106,7 +1146,7 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
} }
this.signatorySearching = true; this.signatorySearching = true;
this.signatorySearchTimer = setTimeout(() => { this.signatorySearchTimer = setTimeout(() => {
this.clientService.searchByText(text, 0, 10).subscribe({ this.clientService.searchByText(text, 0, 10, '', '', true).subscribe({
next: (data: any) => { next: (data: any) => {
this.signatorySearchResults = data?.content || []; this.signatorySearchResults = data?.content || [];
this.signatorySearching = false; this.signatorySearching = false;
@@ -1623,8 +1663,19 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
const generalDetails = this.readOnly ? this.createClientForm.getRawValue() : this.createClientForm.value; const generalDetails = this.readOnly ? this.createClientForm.getRawValue() : this.createClientForm.value;
const dateFormat = this.settingsService.dateFormat; const dateFormat = this.settingsService.dateFormat;
const locale = this.settingsService.language.code; const locale = this.settingsService.language.code;
const isEditMode = !!this.clientData;
for (const key in generalDetails) { for (const key in generalDetails) {
if (generalDetails[key] === '' || key === 'addSavings') { if (key === 'addSavings') {
delete generalDetails[key];
continue;
}
if (generalDetails[key] === '') {
// In edit mode, preserve empty strings for fields that previously had a value so the
// backend can clear them (Fineract treats a missing field as "no change" but an empty
// string as a deliberate clear).
if (isEditMode && this.clientData[key] != null && this.clientData[key] !== '') {
continue;
}
delete generalDetails[key]; delete generalDetails[key];
} }
} }
@@ -114,22 +114,165 @@
<div class="addr-divider"><span>Proof of Address</span></div> <div class="addr-divider"><span>Proof of Address</span></div>
<div class="addr-grid addr-grid--2"> @if (loadingProofs) {
<mat-form-field appearance="outline" class="addr-ff"> <div class="addr-proof-loading">
<mat-label>Address Proof Type</mat-label> <fa-icon icon="spinner" [animation]="'spin'"></fa-icon>
<span matPrefix class="addr-prefix"><mat-icon>verified_user</mat-icon></span> Loading document types…
<mat-select required formControlName="proofType"> </div>
@for (pt of proofTypes; track pt) { }
<mat-option [value]="pt">{{ pt }}</mat-option>
<div class="addr-proof-field">
<label class="addr-proof-label"><mat-icon>description</mat-icon> Document type</label>
<div class="addr-chip-wrap">
@for (group of addressGroups; track group.label; let last = $last) {
<div class="addr-chip-group-label"><mat-icon>folder_open</mat-icon> {{ group.label }}</div>
<div class="addr-chip-grid">
@for (p of group.items; track p.id) {
<span
class="addr-chip"
[class.addr-chip--sel]="isAddrProofSelected(p)"
(click)="selectAddrProof(p)"
(keydown.enter)="selectAddrProof(p)"
tabindex="0"
role="button"
>
<mat-icon class="addr-chip__icon">
{{ isAddrProofSelected(p) ? 'check_circle' : 'radio_button_unchecked' }}
</mat-icon>
{{ p.name }}
</span>
}
</div>
@if (!last) {
<div class="addr-chip-divider"></div>
} }
</mat-select> }
</mat-form-field> </div>
<mat-form-field appearance="outline" class="addr-ff">
<mat-label>Document Number</mat-label>
<span matPrefix class="addr-prefix"><mat-icon>tag</mat-icon></span>
<input matInput formControlName="proofDocNumber" placeholder="e.g. XXXX XXXX XXXX" />
</mat-form-field>
</div> </div>
@if (selectedAddrProof?.info) {
<div class="addr-proof-banner">
<mat-icon>check_circle</mat-icon>
<span>{{ selectedAddrProof.info }}</span>
</div>
}
@if (selectedAddrProof) {
<div class="addr-grid addr-grid--2">
<mat-form-field appearance="outline" class="addr-ff">
<mat-label>{{ selectedAddrProof.documentKeyLabel || 'Document number' }}</mat-label>
<span matPrefix class="addr-prefix"><mat-icon>tag</mat-icon></span>
<input
matInput
formControlName="documentKey"
[placeholder]="selectedAddrProof.documentKeyPlaceholder || ''"
/>
</mat-form-field>
@if (selectedAddrProof.requiresDate) {
<mat-form-field appearance="outline" class="addr-ff">
<mat-label>Issue / bill date</mat-label>
<span matPrefix class="addr-prefix"><mat-icon>event</mat-icon></span>
<input matInput type="date" formControlName="issueDate" />
</mat-form-field>
}
</div>
<div class="addr-upload-grid" [class.addr-upload-grid--two]="selectedAddrProof.sides === 2">
<div class="addr-upload-slot">
<div class="addr-upload-head">
<span class="addr-upload-title">
<mat-icon>image</mat-icon>
{{ selectedAddrProof.sides === 2 ? 'Front side' : selectedAddrProof.name + ' (single copy)' }}
</span>
<span class="addr-upload-req">Required</span>
</div>
<label
class="addr-dropzone"
[class.addr-dropzone--has]="addrFiles.front"
[class.addr-dropzone--req]="!addrFiles.front"
>
<input type="file" accept=".jpg,.jpeg,.png,.pdf" hidden (change)="onAddrFile($event, 'front')" />
@if (addrFiles.front) {
<div class="addr-dropzone__file">
<mat-icon>insert_drive_file</mat-icon>
<span class="addr-dropzone__name">{{ addrFiles.front.name }}</span>
<button
type="button"
mat-icon-button
class="addr-dropzone__remove"
(click)="$event.preventDefault(); removeAddrFile('front')"
>
<mat-icon>close</mat-icon>
</button>
</div>
} @else {
<div class="addr-dropzone__hint">
<mat-icon>cloud_upload</mat-icon>
<span>Click to upload (JPG / PNG / PDF · max 5 MB)</span>
</div>
}
</label>
</div>
@if (selectedAddrProof.sides === 2) {
<div class="addr-upload-slot">
<div class="addr-upload-head">
<span class="addr-upload-title"> <mat-icon>image</mat-icon> Back side </span>
<span class="addr-upload-req">Required</span>
</div>
<label
class="addr-dropzone"
[class.addr-dropzone--has]="addrFiles.back"
[class.addr-dropzone--req]="!addrFiles.back"
>
<input type="file" accept=".jpg,.jpeg,.png,.pdf" hidden (change)="onAddrFile($event, 'back')" />
@if (addrFiles.back) {
<div class="addr-dropzone__file">
<mat-icon>insert_drive_file</mat-icon>
<span class="addr-dropzone__name">{{ addrFiles.back.name }}</span>
<button
type="button"
mat-icon-button
class="addr-dropzone__remove"
(click)="$event.preventDefault(); removeAddrFile('back')"
>
<mat-icon>close</mat-icon>
</button>
</div>
} @else {
<div class="addr-dropzone__hint">
<mat-icon>cloud_upload</mat-icon>
<span>Click to upload (JPG / PNG / PDF · max 5 MB)</span>
</div>
}
</label>
</div>
}
</div>
@if (selectedAddrProof.sides === 2 && addrMergedPreview) {
<div class="addr-merged">
<div class="addr-merged__head">
<span class="addr-merged__title">
<mat-icon>filter_none</mat-icon>
Combined preview (front + back)
</span>
<span class="addr-merged__hint">Saved as a single PNG. Re-crop a side to update the merged file.</span>
</div>
<div class="addr-merged__preview">
<img [src]="addrMergedPreview" alt="Merged address proof" />
</div>
<div class="addr-merged__actions">
<button type="button" mat-stroked-button class="addr-merged__btn" (click)="reCropAddrFile('front')">
<mat-icon>crop</mat-icon> Re-crop front
</button>
<button type="button" mat-stroked-button class="addr-merged__btn" (click)="reCropAddrFile('back')">
<mat-icon>crop</mat-icon> Re-crop back
</button>
</div>
</div>
}
}
</div> </div>
</mat-tab> </mat-tab>
@@ -217,3 +217,346 @@ $muted: #64748b;
color: #1565c0; color: #1565c0;
} }
} }
/* ===================== Address-proof picker ===================== */
.addr-proof-loading {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 14px;
background: #f1f5f9;
border: 1px solid #e2e8f0;
border-radius: 8px;
color: #475569;
font-size: 13px;
margin-bottom: 14px;
}
.addr-proof-field {
margin: 4px 0 18px;
}
.addr-proof-label {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
font-weight: 600;
color: #334155;
margin-bottom: 10px;
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
color: #64748b;
}
}
.addr-chip-wrap {
border: 1px solid #e2e8f0;
border-radius: 12px;
padding: 14px 16px;
background: #f8fafc;
}
.addr-chip-group-label {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.6px;
text-transform: uppercase;
color: #64748b;
margin-bottom: 8px;
mat-icon {
font-size: 14px;
width: 14px;
height: 14px;
color: #94a3b8;
}
}
.addr-chip-grid {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.addr-chip-divider {
height: 1px;
background: #e2e8f0;
margin: 14px 0 10px;
}
.addr-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 14px;
border-radius: 999px;
background: #fff;
border: 1px solid #cbd5e1;
font-size: 13px;
color: #334155;
cursor: pointer;
user-select: none;
transition:
background 0.15s,
border-color 0.15s,
color 0.15s,
box-shadow 0.15s;
&:hover {
border-color: #1976d2;
color: #1976d2;
}
&__icon {
font-size: 16px;
width: 16px;
height: 16px;
color: #94a3b8;
}
&--sel {
background: #e3f2fd;
border-color: #1976d2;
color: #0d47a1;
font-weight: 600;
box-shadow: inset 0 0 0 1px #1976d2;
.addr-chip__icon {
color: #1976d2;
}
}
&:focus-visible {
outline: 2px solid #1976d2;
outline-offset: 2px;
}
}
.addr-proof-banner {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
margin: 12px 0 16px;
border-radius: 10px;
background: #ecfdf5;
border: 1px solid #a7f3d0;
color: #065f46;
font-size: 13px;
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
color: #059669;
flex-shrink: 0;
}
}
.addr-upload-grid {
display: grid;
grid-template-columns: 1fr;
gap: 12px;
margin-top: 6px;
&--two {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@media (width <= 720px) {
grid-template-columns: 1fr !important;
}
}
.addr-upload-slot {
display: flex;
flex-direction: column;
gap: 8px;
}
.addr-upload-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.addr-upload-title {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
font-weight: 600;
color: #334155;
mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
color: #64748b;
}
}
.addr-upload-req {
display: inline-flex;
align-items: center;
padding: 2px 9px;
border-radius: 999px;
background: #fee2e2;
color: #b91c1c;
border: 1px solid #fecaca;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.4px;
text-transform: uppercase;
}
.addr-merged {
margin-top: 16px;
border: 1px solid #e2e8f0;
border-radius: 12px;
background: #fff;
padding: 14px 16px;
&__head {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 6px 16px;
margin-bottom: 10px;
}
&__title {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 13px;
font-weight: 700;
color: #0f172a;
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
color: #2563eb;
}
}
&__hint {
font-size: 12px;
color: #64748b;
}
&__preview {
display: flex;
justify-content: center;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 10px;
padding: 12px;
overflow: hidden;
img {
max-width: 100%;
max-height: 360px;
border-radius: 6px;
box-shadow: 0 1px 6px rgb(15 23 42 / 8%);
}
}
&__actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
margin-top: 12px;
}
&__btn {
mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
margin-right: 4px;
vertical-align: middle;
}
}
}
.addr-dropzone {
display: block;
border: 2px dashed #cbd5e1;
border-radius: 12px;
background: #f8fafc;
padding: 18px 14px;
cursor: pointer;
transition:
background 0.15s,
border-color 0.15s;
&:hover {
background: #f1f5f9;
border-color: #94a3b8;
}
&--req {
border-color: #fca5a5;
background: #fef2f2;
}
&--has {
border-style: solid;
border-color: #86efac;
background: #f0fdf4;
}
&__hint {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
color: #64748b;
font-size: 13px;
mat-icon {
font-size: 22px;
width: 22px;
height: 22px;
color: #94a3b8;
}
}
&__file {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
mat-icon {
color: #16a34a;
flex-shrink: 0;
}
}
&__name {
flex: 1;
min-width: 0;
font-size: 13px;
font-weight: 600;
color: #0f172a;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&__remove {
flex-shrink: 0;
}
}
@@ -14,7 +14,10 @@ import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { MatCheckbox } from '@angular/material/checkbox'; import { MatCheckbox } from '@angular/material/checkbox';
import { MatTabsModule } from '@angular/material/tabs'; import { MatTabsModule } from '@angular/material/tabs';
import { MatIcon } from '@angular/material/icon'; import { MatIcon } from '@angular/material/icon';
import { MatIconButton, MatButton } from '@angular/material/button';
import { ClientsService } from '../../clients.service'; import { ClientsService } from '../../clients.service';
import { ProofType, normalizeProofType, groupProofsByCategory } from '../../shared/kyc-proof-types';
import { KycUploadHelperService } from '../../shared/kyc-upload-helper.service';
@Component({ @Component({
selector: 'mifosx-client-india-address-step', selector: 'mifosx-client-india-address-step',
@@ -25,13 +28,16 @@ import { ClientsService } from '../../clients.service';
FaIconComponent, FaIconComponent,
MatCheckbox, MatCheckbox,
MatTabsModule, MatTabsModule,
MatIcon MatIcon,
MatIconButton,
MatButton
] ]
}) })
export class ClientIndiaAddressStepComponent implements OnInit, OnChanges { export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
private fb = inject(UntypedFormBuilder); private fb = inject(UntypedFormBuilder);
private http = inject(HttpClient); private http = inject(HttpClient);
private clientsService = inject(ClientsService); private clientsService = inject(ClientsService);
private kycUpload = inject(KycUploadHelperService);
/** Client ID — when set, loads existing addresses for edit mode */ /** Client ID — when set, loads existing addresses for edit mode */
@Input() clientId: string = ''; @Input() clientId: string = '';
@@ -66,11 +72,12 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
], ],
country: [{ value: 'India', disabled: true }], country: [{ value: 'India', disabled: true }],
landmark: [''], landmark: [''],
proofType: [ proofId: [
'', '',
Validators.required Validators.required
], ],
proofDocNumber: [''] documentKey: [''],
issueDate: ['']
}); });
currentForm: UntypedFormGroup = this.fb.group({ currentForm: UntypedFormGroup = this.fb.group({
@@ -166,14 +173,15 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
correspondence: [] correspondence: []
}; };
proofTypes = [ /** KYC address-proof picker state (mirrors identities-tab dialog) */
'Aadhaar Card', addressProofs: ProofType[] = [];
'Passport', addressGroups: { label: string; items: ProofType[] }[] = [];
'Voter ID', selectedAddrProof: ProofType | null = null;
'Driving Licence', loadingProofs = false;
'Utility Bill (not older than 3 months)', addrFiles: { front: File | null; back: File | null } = { front: null, back: null };
'Bank Statement' /** Combined front+back PNG (built automatically once both image sides are ready) */
]; addrMergedFile: File | null = null;
addrMergedPreview: string | null = null;
residenceTypes = [ residenceTypes = [
'Owned', 'Owned',
@@ -206,11 +214,116 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges {
private typeToTab: Record<string, string> = {}; private typeToTab: Record<string, string> = {};
ngOnInit(): void { ngOnInit(): void {
this.loadProofTypes();
if (this.clientId) { if (this.clientId) {
this.loadExistingAddresses(); this.loadExistingAddresses();
} }
} }
private loadProofTypes(): void {
this.loadingProofs = true;
this.clientsService.getKycProofTypes().subscribe({
next: (res: any) => {
const activeFilter = (p: any) => p?.active !== false;
this.addressProofs = (res?.addressProofs || []).filter(activeFilter).map(normalizeProofType);
this.addressGroups = groupProofsByCategory(this.addressProofs);
this.loadingProofs = false;
},
error: () => {
this.loadingProofs = false;
}
});
}
selectAddrProof(proof: ProofType): void {
this.selectedAddrProof = proof;
this.permanentForm.get('proofId')?.setValue(proof.id);
this.addrFiles = { front: null, back: null };
this.addrMergedFile = null;
this.addrMergedPreview = null;
}
isAddrProofSelected(proof: ProofType): boolean {
return this.selectedAddrProof?.id === proof.id;
}
async onAddrFile(event: Event, slot: 'front' | 'back'): Promise<void> {
const input = event.target as HTMLInputElement;
const raw = input.files?.[0];
input.value = '';
if (!raw) return;
if (raw.size > 5 * 1024 * 1024) return;
const cropped = await this.kycUpload.pickWithCrop(raw, `Crop ${slot} side`);
if (!cropped) return;
this.addrFiles[slot] = cropped;
await this.refreshAddrMerged();
}
async reCropAddrFile(slot: 'front' | 'back'): Promise<void> {
const current = this.addrFiles[slot];
if (!current || !current.type.startsWith('image/')) return;
const cropped = await this.kycUpload.pickWithCrop(current, `Re-crop ${slot} side`);
if (!cropped) return;
this.addrFiles[slot] = cropped;
await this.refreshAddrMerged();
}
removeAddrFile(slot: 'front' | 'back'): void {
this.addrFiles[slot] = null;
this.addrMergedFile = null;
this.addrMergedPreview = null;
}
private async refreshAddrMerged(): Promise<void> {
const proof = this.selectedAddrProof;
if (!proof || proof.sides !== 2 || !this.addrFiles.front || !this.addrFiles.back) {
this.addrMergedFile = null;
this.addrMergedPreview = null;
return;
}
const merged = await this.kycUpload.mergeFrontBack(
this.addrFiles.front,
this.addrFiles.back,
proof.name || 'address-proof'
);
if (merged) {
this.addrMergedFile = merged.file;
this.addrMergedPreview = merged.preview;
} else {
this.addrMergedFile = null;
this.addrMergedPreview = null;
}
}
/**
* Files + proof metadata for the parent to persist (e.g. via client identifier API).
* When both image sides are present they're sent as a single combined PNG.
*/
get addressProofPayload(): {
proof: ProofType | null;
proofId: any;
documentKey: string;
issueDate: string;
files: { slot: string; file: File }[];
} {
const files: { slot: string; file: File }[] = [];
if (this.selectedAddrProof?.sides === 2 && this.addrMergedFile) {
files.push({ slot: 'Combined', file: this.addrMergedFile });
} else {
if (this.addrFiles.front) files.push({ slot: 'Front', file: this.addrFiles.front });
if (this.selectedAddrProof?.sides === 2 && this.addrFiles.back) {
files.push({ slot: 'Back', file: this.addrFiles.back });
}
}
return {
proof: this.selectedAddrProof,
proofId: this.permanentForm.get('proofId')?.value || '',
documentKey: this.permanentForm.get('documentKey')?.value || '',
issueDate: this.permanentForm.get('issueDate')?.value || '',
files
};
}
ngOnChanges(changes: SimpleChanges): void { ngOnChanges(changes: SimpleChanges): void {
if (changes['readOnly']) { if (changes['readOnly']) {
const forms = [ const forms = [
@@ -640,33 +640,163 @@
} }
</h4> </h4>
<!-- Chip-grouped document type picker -->
<div class="kyc-doc-pickbox">
<div class="kyc-doc-pickbox__head">
<mat-icon class="kyc-doc-pickbox__icon">badge</mat-icon>
<div class="kyc-doc-pickbox__titles">
<p class="kyc-doc-pickbox__title">Identity proof</p>
<p class="kyc-doc-pickbox__sub">Select document type — upload requirements will update automatically</p>
</div>
</div>
<div class="kyc-doc-field">
<label class="kyc-doc-label"><mat-icon>description</mat-icon> Document type</label>
<div class="kyc-doc-chip-wrap">
@for (group of kycDocGroups; track group.label; let last = $last) {
<div class="kyc-doc-chip-group-label"><mat-icon>folder_open</mat-icon> {{ group.label }}</div>
<div class="kyc-doc-chip-grid">
@for (p of group.items; track p.id) {
<span
class="kyc-doc-chip"
[class.kyc-doc-chip--sel]="isKycDocSelected(p)"
(click)="selectKycDoc(p)"
(keydown.enter)="selectKycDoc(p)"
tabindex="0"
role="button"
>
<mat-icon class="kyc-doc-chip__icon">
{{ isKycDocSelected(p) ? 'check_circle' : 'radio_button_unchecked' }}
</mat-icon>
{{ p.name }}
</span>
}
</div>
@if (!last) {
<div class="kyc-doc-chip-divider"></div>
}
}
</div>
@if (kycForm.get('kycDocumentName')?.hasError('required') && kycForm.get('kycDocumentName')?.touched) {
<div class="kyc-doc-error">KYC Document Type is <strong>required</strong></div>
}
</div>
@if (selectedKycDoc?.info) {
<div class="kyc-doc-banner">
<mat-icon>check_circle</mat-icon>
<span>{{ selectedKycDoc.info }}</span>
</div>
}
@if (selectedKycDoc) {
<div class="kyc-doc-upload-grid" [class.kyc-doc-upload-grid--two]="selectedKycDoc.sides === 2">
<div class="kyc-doc-upload-slot">
<div class="kyc-doc-upload-head">
<span class="kyc-doc-upload-title">
<mat-icon>image</mat-icon>
{{ selectedKycDoc.sides === 2 ? 'Front side' : selectedKycDoc.name + ' (single copy)' }}
</span>
<span class="kyc-doc-upload-req">Required</span>
</div>
<label
class="kyc-doc-dropzone"
[class.kyc-doc-dropzone--has]="kycDocFiles.front"
[class.kyc-doc-dropzone--req]="!kycDocFiles.front"
>
<input type="file" accept=".jpg,.jpeg,.png,.pdf" hidden (change)="onKycDocFile($event, 'front')" />
@if (kycDocFiles.front) {
<div class="kyc-doc-dropzone__file">
<mat-icon>insert_drive_file</mat-icon>
<span class="kyc-doc-dropzone__name">{{ kycDocFiles.front.name }}</span>
<button
type="button"
mat-icon-button
class="kyc-doc-dropzone__remove"
(click)="$event.preventDefault(); removeKycDocFile('front')"
>
<mat-icon>close</mat-icon>
</button>
</div>
} @else {
<div class="kyc-doc-dropzone__hint">
<mat-icon>cloud_upload</mat-icon>
<span>Click to upload (JPG / PNG / PDF · max 5 MB)</span>
</div>
}
</label>
</div>
@if (selectedKycDoc.sides === 2) {
<div class="kyc-doc-upload-slot">
<div class="kyc-doc-upload-head">
<span class="kyc-doc-upload-title"> <mat-icon>image</mat-icon> Back side </span>
<span class="kyc-doc-upload-req">Required</span>
</div>
<label
class="kyc-doc-dropzone"
[class.kyc-doc-dropzone--has]="kycDocFiles.back"
[class.kyc-doc-dropzone--req]="!kycDocFiles.back"
>
<input type="file" accept=".jpg,.jpeg,.png,.pdf" hidden (change)="onKycDocFile($event, 'back')" />
@if (kycDocFiles.back) {
<div class="kyc-doc-dropzone__file">
<mat-icon>insert_drive_file</mat-icon>
<span class="kyc-doc-dropzone__name">{{ kycDocFiles.back.name }}</span>
<button
type="button"
mat-icon-button
class="kyc-doc-dropzone__remove"
(click)="$event.preventDefault(); removeKycDocFile('back')"
>
<mat-icon>close</mat-icon>
</button>
</div>
} @else {
<div class="kyc-doc-dropzone__hint">
<mat-icon>cloud_upload</mat-icon>
<span>Click to upload (JPG / PNG / PDF · max 5 MB)</span>
</div>
}
</label>
</div>
}
</div>
@if (selectedKycDoc.sides === 2 && kycDocMergedPreview) {
<div class="kyc-merged">
<div class="kyc-merged__head">
<span class="kyc-merged__title">
<mat-icon>filter_none</mat-icon>
Combined preview (front + back)
</span>
<span class="kyc-merged__hint">Saved as a single PNG. Re-crop a side to update the merged file.</span>
</div>
<div class="kyc-merged__preview">
<img [src]="kycDocMergedPreview" alt="Merged KYC document" />
</div>
<div class="kyc-merged__actions">
<button type="button" mat-stroked-button class="kyc-merged__btn" (click)="reCropKycDocFile('front')">
<mat-icon>crop</mat-icon> Re-crop front
</button>
<button type="button" mat-stroked-button class="kyc-merged__btn" (click)="reCropKycDocFile('back')">
<mat-icon>crop</mat-icon> Re-crop back
</button>
</div>
</div>
}
}
</div>
<div class="form-grid"> <div class="form-grid">
<mat-form-field appearance="outline" class="form-field"> <mat-form-field appearance="outline" class="form-field">
<mat-label>KYC Document Type</mat-label> <mat-label>{{ selectedKycDoc?.documentKeyLabel || 'Document Number' }}</mat-label>
<span matPrefix class="field-prefix-icon"><mat-icon>description</mat-icon></span>
<mat-select required formControlName="kycDocumentName">
@for (doc of kycDocumentTypes; track doc) {
<mat-option [value]="doc.name">
{{ doc.name }}
@if (doc.mandatory) {
<span class="doc-mandatory-star"> *</span>
}
</mat-option>
}
</mat-select>
@if (kycForm.get('kycDocumentName')?.hasError('required') && kycForm.get('kycDocumentName')?.touched) {
<mat-error>KYC Document Type is <strong>required</strong></mat-error>
}
</mat-form-field>
<mat-form-field appearance="outline" class="form-field">
<mat-label>Document Number</mat-label>
<span matPrefix class="field-prefix-icon"><mat-icon>tag</mat-icon></span> <span matPrefix class="field-prefix-icon"><mat-icon>tag</mat-icon></span>
<input <input
matInput matInput
required required
formControlName="kycDocumentNumber" formControlName="kycDocumentNumber"
placeholder="As printed on document" [placeholder]="selectedKycDoc?.documentKeyPlaceholder || 'As printed on document'"
maxlength="30" maxlength="30"
style="text-transform: uppercase" style="text-transform: uppercase"
(input)=" (input)="
@@ -1005,9 +1135,14 @@
<h4 class="section-title"> <h4 class="section-title">
<fa-icon icon="camera" class="section-icon"></fa-icon> <fa-icon icon="camera" class="section-icon"></fa-icon>
Photograph &amp; Signature Photograph &amp; Signature
<span class="section-required-tag">Required</span>
</h4> </h4>
<div class="photo-sig-grid"> <div class="photo-sig-grid">
<div class="upload-box" [class.upload-box--has-preview]="photographPreview"> <div
class="upload-box"
[class.upload-box--has-preview]="photographPreview"
[class.upload-box--missing]="!photographPreview && photoSigTouched"
>
@if (photographPreview) { @if (photographPreview) {
<div class="upload-preview"> <div class="upload-preview">
<img [src]="photographPreview" alt="Photo" class="upload-preview__img upload-preview__img--photo" /> <img [src]="photographPreview" alt="Photo" class="upload-preview__img upload-preview__img--photo" />
@@ -1023,15 +1158,25 @@
</div> </div>
} @else { } @else {
<div class="upload-box__icon"><fa-icon icon="user"></fa-icon></div> <div class="upload-box__icon"><fa-icon icon="user"></fa-icon></div>
<span class="upload-box__label">Client Photograph</span> <span class="upload-box__label">
Client Photograph
<span class="upload-box__req">*</span>
</span>
<span class="upload-box__hint">JPG / PNG, max 2MB</span> <span class="upload-box__hint">JPG / PNG, max 2MB</span>
<label class="upload-box__btn"> <label class="upload-box__btn">
<input type="file" accept="image/*" hidden (change)="onPhotoSelected($event)" /> <input type="file" accept="image/*" hidden (change)="onPhotoSelected($event)" />
<fa-icon icon="camera"></fa-icon> Choose &amp; Crop <fa-icon icon="camera"></fa-icon> Choose &amp; Crop
</label> </label>
@if (photoSigTouched) {
<span class="upload-box__error">Photograph is required</span>
}
} }
</div> </div>
<div class="upload-box" [class.upload-box--has-preview]="signaturePreview"> <div
class="upload-box"
[class.upload-box--has-preview]="signaturePreview"
[class.upload-box--missing]="!signaturePreview && photoSigTouched"
>
@if (signaturePreview) { @if (signaturePreview) {
<div class="upload-preview"> <div class="upload-preview">
<img [src]="signaturePreview" alt="Signature" class="upload-preview__img upload-preview__img--sig" /> <img [src]="signaturePreview" alt="Signature" class="upload-preview__img upload-preview__img--sig" />
@@ -1047,12 +1192,18 @@
</div> </div>
} @else { } @else {
<div class="upload-box__icon"><fa-icon icon="pen"></fa-icon></div> <div class="upload-box__icon"><fa-icon icon="pen"></fa-icon></div>
<span class="upload-box__label">Specimen Signature</span> <span class="upload-box__label">
Specimen Signature
<span class="upload-box__req">*</span>
</span>
<span class="upload-box__hint">Scanned image, max 1MB</span> <span class="upload-box__hint">Scanned image, max 1MB</span>
<label class="upload-box__btn"> <label class="upload-box__btn">
<input type="file" accept="image/*" hidden (change)="onSignatureSelected($event)" /> <input type="file" accept="image/*" hidden (change)="onSignatureSelected($event)" />
<fa-icon icon="crop"></fa-icon> Choose &amp; Crop <fa-icon icon="crop"></fa-icon> Choose &amp; Crop
</label> </label>
@if (photoSigTouched) {
<span class="upload-box__error">Signature is required</span>
}
} }
</div> </div>
</div> </div>
@@ -23,6 +23,19 @@
color: var(--md-sys-color-primary, #1074b9); color: var(--md-sys-color-primary, #1074b9);
font-size: 14px; font-size: 14px;
} }
.section-required-tag {
margin-left: 6px;
padding: 2px 9px;
border-radius: 999px;
background: #fee2e2;
color: #b91c1c;
border: 1px solid #fecaca;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.4px;
text-transform: uppercase;
}
} }
.kyc-badge { .kyc-badge {
@@ -948,12 +961,23 @@
border-radius: 10px; border-radius: 10px;
background: #f9fafb; background: #f9fafb;
text-align: center; text-align: center;
transition: border-color 0.15s; transition:
border-color 0.15s,
background 0.15s;
&:hover { &:hover {
border-color: #1353a0; border-color: #1353a0;
} }
&--missing {
border-color: #dc2626;
background: #fef2f2;
&:hover {
border-color: #b91c1c;
}
}
&__icon { &__icon {
width: 44px; width: 44px;
height: 44px; height: 44px;
@@ -972,6 +996,19 @@
color: #0f172a; color: #0f172a;
} }
&__req {
color: #dc2626;
font-weight: 700;
margin-left: 2px;
}
&__error {
margin-top: 6px;
font-size: 12px;
font-weight: 600;
color: #b91c1c;
}
&__hint { &__hint {
font-size: 11px; font-size: 11px;
color: #94a3b8; color: #94a3b8;
@@ -1214,3 +1251,393 @@
font-weight: 600; font-weight: 600;
} }
} }
/* ===================== KYC Document chip-grouped picker ===================== */
.kyc-doc-pickbox {
border: 1px solid #e2e8f0;
border-radius: 14px;
background: #fff;
padding: 16px 18px;
margin: 6px 0 14px;
box-shadow: 0 1px 3px rgb(15 23 42 / 4%);
&__head {
display: flex;
align-items: center;
gap: 12px;
padding-bottom: 10px;
border-bottom: 1px solid #f1f5f9;
margin-bottom: 14px;
}
&__icon {
color: #2563eb;
background: #eff6ff;
border-radius: 999px;
width: 36px;
height: 36px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 20px;
flex-shrink: 0;
}
&__titles {
min-width: 0;
}
&__title {
margin: 0;
font-size: 14px;
font-weight: 700;
color: #0f172a;
}
&__sub {
margin: 2px 0 0;
font-size: 12px;
color: #64748b;
}
}
.kyc-doc-field {
margin-bottom: 4px;
}
.kyc-doc-label {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
font-weight: 600;
color: #334155;
margin-bottom: 10px;
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
color: #64748b;
}
}
.kyc-doc-chip-wrap {
border: 1px solid #e2e8f0;
border-radius: 12px;
padding: 14px 16px;
background: #f8fafc;
}
.kyc-doc-chip-group-label {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.6px;
text-transform: uppercase;
color: #64748b;
margin-bottom: 8px;
mat-icon {
font-size: 14px;
width: 14px;
height: 14px;
color: #94a3b8;
}
}
.kyc-doc-chip-grid {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.kyc-doc-chip-divider {
height: 1px;
background: #e2e8f0;
margin: 14px 0 10px;
}
.kyc-doc-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 14px;
border-radius: 999px;
background: #fff;
border: 1px solid #cbd5e1;
font-size: 13px;
color: #334155;
cursor: pointer;
user-select: none;
transition:
background 0.15s,
border-color 0.15s,
color 0.15s,
box-shadow 0.15s;
&:hover {
border-color: #1976d2;
color: #1976d2;
}
&__icon {
font-size: 16px;
width: 16px;
height: 16px;
color: #94a3b8;
}
&--sel {
background: #e3f2fd;
border-color: #1976d2;
color: #0d47a1;
font-weight: 600;
box-shadow: inset 0 0 0 1px #1976d2;
.kyc-doc-chip__icon {
color: #1976d2;
}
}
&:focus-visible {
outline: 2px solid #1976d2;
outline-offset: 2px;
}
}
.kyc-doc-banner {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
margin-top: 12px;
border-radius: 10px;
background: #ecfdf5;
border: 1px solid #a7f3d0;
color: #065f46;
font-size: 13px;
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
color: #059669;
flex-shrink: 0;
}
}
.kyc-doc-error {
margin-top: 8px;
color: #b91c1c;
font-size: 12px;
padding-left: 4px;
strong {
font-weight: 700;
}
}
/* ===================== KYC Document upload dropzones ===================== */
.kyc-doc-upload-grid {
display: grid;
grid-template-columns: 1fr;
gap: 12px;
margin-top: 14px;
&--two {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@media (width <= 720px) {
grid-template-columns: 1fr !important;
}
}
.kyc-doc-upload-slot {
display: flex;
flex-direction: column;
gap: 8px;
}
.kyc-doc-upload-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.kyc-doc-upload-title {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
font-weight: 600;
color: #334155;
mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
color: #64748b;
}
}
.kyc-doc-upload-req {
display: inline-flex;
align-items: center;
padding: 2px 9px;
border-radius: 999px;
background: #fee2e2;
color: #b91c1c;
border: 1px solid #fecaca;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.4px;
text-transform: uppercase;
}
.kyc-merged {
margin-top: 16px;
border: 1px solid #e2e8f0;
border-radius: 12px;
background: #fff;
padding: 14px 16px;
&__head {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 6px 16px;
margin-bottom: 10px;
}
&__title {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 13px;
font-weight: 700;
color: #0f172a;
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
color: #2563eb;
}
}
&__hint {
font-size: 12px;
color: #64748b;
}
&__preview {
display: flex;
justify-content: center;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 10px;
padding: 12px;
overflow: hidden;
img {
max-width: 100%;
max-height: 360px;
border-radius: 6px;
box-shadow: 0 1px 6px rgb(15 23 42 / 8%);
}
}
&__actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
margin-top: 12px;
}
&__btn {
mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
margin-right: 4px;
vertical-align: middle;
}
}
}
.kyc-doc-dropzone {
display: block;
border: 2px dashed #cbd5e1;
border-radius: 12px;
background: #f8fafc;
padding: 18px 14px;
cursor: pointer;
transition:
background 0.15s,
border-color 0.15s;
&:hover {
background: #f1f5f9;
border-color: #94a3b8;
}
&--req {
border-color: #fca5a5;
background: #fef2f2;
}
&--has {
border-style: solid;
border-color: #86efac;
background: #f0fdf4;
}
&__hint {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
color: #64748b;
font-size: 13px;
mat-icon {
font-size: 22px;
width: 22px;
height: 22px;
color: #94a3b8;
}
}
&__file {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
mat-icon {
color: #16a34a;
flex-shrink: 0;
}
}
&__name {
flex: 1;
min-width: 0;
font-size: 13px;
font-weight: 600;
color: #0f172a;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&__remove {
flex-shrink: 0;
}
}
@@ -8,19 +8,28 @@
import { Component, Input, OnInit, OnChanges, OnDestroy, SimpleChanges, inject } from '@angular/core'; import { Component, Input, OnInit, OnChanges, OnDestroy, SimpleChanges, inject } from '@angular/core';
import { UntypedFormBuilder, UntypedFormGroup, Validators, FormsModule } from '@angular/forms'; import { UntypedFormBuilder, UntypedFormGroup, Validators, FormsModule } from '@angular/forms';
import { MatDialog, MAT_DIALOG_DATA, MatDialogContent, MatDialogActions } from '@angular/material/dialog'; import {
MatDialog,
MAT_DIALOG_DATA,
MatDialogContent,
MatDialogActions,
MatDialogClose
} from '@angular/material/dialog';
import { Inject } from '@angular/core'; import { Inject } from '@angular/core';
import { MatProgressSpinner } from '@angular/material/progress-spinner'; import { MatProgressSpinner } from '@angular/material/progress-spinner';
import { MatSelectTrigger } from '@angular/material/select'; import { MatSelectTrigger } from '@angular/material/select';
import { MatDivider } from '@angular/material/divider'; import { MatDivider } from '@angular/material/divider';
import { MatIcon } from '@angular/material/icon'; import { MatIcon } from '@angular/material/icon';
import { MatIconButton, MatButton } from '@angular/material/button';
import { MatCheckbox } from '@angular/material/checkbox'; import { MatCheckbox } from '@angular/material/checkbox';
import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { ClientsService } from '../../clients.service'; import { ClientsService } from '../../clients.service';
import { SettingsService } from 'app/settings/settings.service'; import { SettingsService } from 'app/settings/settings.service';
import { Dates } from 'app/core/utils/dates'; import { Dates } from 'app/core/utils/dates';
import { ProofType, normalizeProofType, groupProofsByCategory } from '../../shared/kyc-proof-types';
import { KycUploadHelperService } from '../../shared/kyc-upload-helper.service';
@Component({ @Component({
selector: 'mifosx-client-kyc-step', selector: 'mifosx-client-kyc-step',
@@ -31,6 +40,8 @@ import { Dates } from 'app/core/utils/dates';
FormsModule, FormsModule,
MatDivider, MatDivider,
MatIcon, MatIcon,
MatIconButton,
MatButton,
MatCheckbox, MatCheckbox,
MatProgressSpinner, MatProgressSpinner,
FaIconComponent, FaIconComponent,
@@ -43,6 +54,7 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
private clientsService = inject(ClientsService); private clientsService = inject(ClientsService);
private settingsService = inject(SettingsService); private settingsService = inject(SettingsService);
private dateUtils = inject(Dates); private dateUtils = inject(Dates);
private kycUpload = inject(KycUploadHelperService);
/** Set by parent after client is created */ /** Set by parent after client is created */
@Input() clientId: string = ''; @Input() clientId: string = '';
@@ -67,15 +79,47 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
/** KYC Document Types — populated from API template, falls back to defaults */ /** KYC Document Types — populated from API template, falls back to defaults */
kycDocumentTypes: { name: string; code: string; mandatory: boolean }[] = []; kycDocumentTypes: { name: string; code: string; mandatory: boolean }[] = [];
/** Chip-group state for KYC document picker (mirrors identity proof in identifier dialog) */
kycDocProofs: ProofType[] = [];
kycDocGroups: { label: string; items: ProofType[] }[] = [];
selectedKycDoc: ProofType | null = null;
/** Front/back files for the chosen KYC document */
kycDocFiles: { front: File | null; back: File | null } = { front: null, back: null };
/** Combined front+back PNG (built automatically once both image sides are ready) */
kycDocMergedFile: File | null = null;
kycDocMergedPreview: string | null = null;
/** Set true after a save attempt so we can highlight missing photo/signature boxes */
photoSigTouched = false;
/** Either an in-memory crop or an image already on the server */
get hasPhoto(): boolean {
return !!this.photographPreview;
}
get hasSignature(): boolean {
return !!this.signaturePreview;
}
/** Photo + signature are mandatory before leaving the KYC tab */
get photoSigComplete(): boolean {
return this.hasPhoto && this.hasSignature;
}
/** Mark uploads as touched so the UI shows the missing-required state */
markPhotoSigTouched(): void {
this.photoSigTouched = true;
}
/** Fallback document types when template API is unavailable */ /** Fallback document types when template API is unavailable */
private defaultDocumentTypes = [ private defaultDocumentTypes = [
'Aadhaar Card', { name: 'Aadhaar Card', code: 'AADHAAR' },
'Passport', { name: 'Passport', code: 'PASSPORT' },
'PAN Card', { name: 'PAN Card', code: 'PAN' },
'Driving Licence', { name: 'Driving Licence', code: 'DRIVING_LICENSE' },
'Voter ID', { name: 'Voter ID', code: 'VOTER_ID' },
'NREGA Job Card', { name: 'NREGA Job Card', code: 'NREGA_JOB_CARD' },
'National Population Register Letter' { name: 'National Population Register Letter', code: 'NPR_LETTER' }
]; ];
/** Constitution-based KYC document types for non-individual clients */ /** Constitution-based KYC document types for non-individual clients */
private constitutionDocTypes: Record<string, { name: string; mandatory: boolean }[]> = { private constitutionDocTypes: Record<string, { name: string; mandatory: boolean }[]> = {
@@ -371,6 +415,16 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
}); });
ngOnInit(): void { ngOnInit(): void {
// Seed defaults immediately so the picker has options before clientId is set
// (template API requires clientId; user can still pick a type pre-save).
if (!this.kycDocumentTypes.length) {
this.kycDocumentTypes = this.defaultDocumentTypes.map((d) => ({
name: d.name,
code: d.code,
mandatory: false
}));
this.rebuildKycDocPicker();
}
this.loadExistingKycIfNeeded(); this.loadExistingKycIfNeeded();
this.loadKycTemplate(); this.loadKycTemplate();
if (this.isNonIndividual && this.constitutionMeta) { if (this.isNonIndividual && this.constitutionMeta) {
@@ -412,9 +466,111 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
// Reset selected document type since options changed // Reset selected document type since options changed
this.kycForm.get('kycDocumentName')?.setValue(''); this.kycForm.get('kycDocumentName')?.setValue('');
this.kycForm.get('kycDocumentName')?.updateValueAndValidity(); this.kycForm.get('kycDocumentName')?.updateValueAndValidity();
this.rebuildKycDocPicker();
} }
} }
/** Rebuild chip-group picker model from kycDocumentTypes */
private rebuildKycDocPicker(): void {
this.kycDocProofs = (this.kycDocumentTypes || []).map((d, i) =>
normalizeProofType({ id: d.code || d.name || i, name: d.name, code: d.code, description: d.code || d.name })
);
this.kycDocGroups = groupProofsByCategory(this.kycDocProofs);
// Sync selected proof with current form value (e.g. on edit-mode prefill)
const currentName = this.kycForm?.get('kycDocumentName')?.value;
this.selectedKycDoc = currentName ? this.kycDocProofs.find((p) => p.name === currentName) || null : null;
}
selectKycDoc(proof: ProofType): void {
this.selectedKycDoc = proof;
this.kycForm.get('kycDocumentName')?.setValue(proof.name);
// Reset uploads + merged preview when document type changes
this.kycDocFiles = { front: null, back: null };
this.kycDocMergedFile = null;
this.kycDocMergedPreview = null;
}
isKycDocSelected(proof: ProofType): boolean {
return this.selectedKycDoc?.code === proof.code && this.selectedKycDoc?.name === proof.name;
}
async onKycDocFile(event: Event, slot: 'front' | 'back'): Promise<void> {
const input = event.target as HTMLInputElement;
const raw = input.files?.[0];
input.value = ''; // allow re-picking the same filename
if (!raw) return;
if (raw.size > 5 * 1024 * 1024) return;
const cropped = await this.kycUpload.pickWithCrop(raw, `Crop ${slot} side`);
if (!cropped) return;
this.kycDocFiles[slot] = cropped;
await this.refreshKycDocMerged();
}
async reCropKycDocFile(slot: 'front' | 'back'): Promise<void> {
const current = this.kycDocFiles[slot];
if (!current || !current.type.startsWith('image/')) return;
const cropped = await this.kycUpload.pickWithCrop(current, `Re-crop ${slot} side`);
if (!cropped) return;
this.kycDocFiles[slot] = cropped;
await this.refreshKycDocMerged();
}
removeKycDocFile(slot: 'front' | 'back'): void {
this.kycDocFiles[slot] = null;
this.kycDocMergedFile = null;
this.kycDocMergedPreview = null;
}
private async refreshKycDocMerged(): Promise<void> {
const proof = this.selectedKycDoc;
if (!proof || proof.sides !== 2 || !this.kycDocFiles.front || !this.kycDocFiles.back) {
this.kycDocMergedFile = null;
this.kycDocMergedPreview = null;
return;
}
const merged = await this.kycUpload.mergeFrontBack(
this.kycDocFiles.front,
this.kycDocFiles.back,
proof.name || 'kyc-document'
);
if (merged) {
this.kycDocMergedFile = merged.file;
this.kycDocMergedPreview = merged.preview;
} else {
this.kycDocMergedFile = null;
this.kycDocMergedPreview = null;
}
}
/**
* Files + proof metadata for the parent to persist as a client identifier.
* When both image sides are present they're sent as a single combined PNG;
* otherwise the individual files are returned.
*/
get kycDocPayload(): {
proof: ProofType | null;
proofId: any;
documentKey: string;
files: { slot: string; file: File }[];
} {
const files: { slot: string; file: File }[] = [];
if (this.selectedKycDoc?.sides === 2 && this.kycDocMergedFile) {
files.push({ slot: 'Combined', file: this.kycDocMergedFile });
} else {
if (this.kycDocFiles.front) files.push({ slot: 'Front', file: this.kycDocFiles.front });
if (this.selectedKycDoc?.sides === 2 && this.kycDocFiles.back) {
files.push({ slot: 'Back', file: this.kycDocFiles.back });
}
}
return {
proof: this.selectedKycDoc,
proofId: this.selectedKycDoc?.id ?? '',
documentKey: this.kycForm.get('kycDocumentNumber')?.value || '',
files
};
}
/** Load existing KYC data if clientId is set (edit mode) */ /** Load existing KYC data if clientId is set (edit mode) */
private loadExistingKycIfNeeded(): void { private loadExistingKycIfNeeded(): void {
if (!this.clientId || this.kycLoaded) return; if (!this.clientId || this.kycLoaded) return;
@@ -469,6 +625,10 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
form15Type: data.form15Type || '15G', form15Type: data.form15Type || '15G',
form15FY: data.form15FY || '' form15FY: data.form15FY || ''
}); });
// Sync chip-picker selection with the prefilled document name
if (data.kycDocumentName) {
this.selectedKycDoc = this.kycDocProofs.find((p) => p.name === data.kycDocumentName) || null;
}
// Set verification statuses from existing data // Set verification statuses from existing data
if (data.panVerified) { if (data.panVerified) {
this.panVerifyStatus = 'VERIFIED'; this.panVerifyStatus = 'VERIFIED';
@@ -568,16 +728,18 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
this.kycForm.get('kycDocumentName')?.setValidators(Validators.required); this.kycForm.get('kycDocumentName')?.setValidators(Validators.required);
} }
this.kycForm.get('kycDocumentName')?.updateValueAndValidity(); this.kycForm.get('kycDocumentName')?.updateValueAndValidity();
this.rebuildKycDocPicker();
} }
}, },
error: () => { error: () => {
// Fallback to defaults if template API unavailable // Fallback to defaults if template API unavailable
this.kycTemplateLoaded = true; this.kycTemplateLoaded = true;
this.kycDocumentTypes = this.defaultDocumentTypes.map((name) => ({ this.kycDocumentTypes = this.defaultDocumentTypes.map((d) => ({
name, name: d.name,
code: name, code: d.code,
mandatory: false mandatory: false
})); }));
this.rebuildKycDocPicker();
} }
}); });
} }
@@ -1232,6 +1394,7 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
MatIcon, MatIcon,
MatDialogContent, MatDialogContent,
MatDialogActions, MatDialogActions,
MatDialogClose,
STANDALONE_SHARED_IMPORTS STANDALONE_SHARED_IMPORTS
] ]
}) })
@@ -77,6 +77,74 @@
} }
</div> </div>
<!-- KYC Verification Status (Aadhaar + PAN) -->
<div class="kyc-status-card" [class.kyc-status-card--complete]="kycComplete">
<div class="kyc-status-card__head">
<div class="kyc-status-card__title">
<fa-icon [icon]="kycComplete ? 'shield-alt' : 'user-shield'" class="kyc-status-card__icon"></fa-icon>
<span>KYC Verification</span>
</div>
@if (kycComplete) {
<span class="kyc-status-card__badge kyc-status-card__badge--ok">
<fa-icon icon="check-circle"></fa-icon>
KYC Done
</span>
} @else if (panVerified || aadhaarVerified) {
<span class="kyc-status-card__badge kyc-status-card__badge--partial">
<fa-icon icon="clock"></fa-icon>
Partially Verified
</span>
} @else {
<span class="kyc-status-card__badge kyc-status-card__badge--pending">
<fa-icon icon="exclamation-circle"></fa-icon>
Not Verified
</span>
}
</div>
<div class="kyc-status-card__body">
<div class="kyc-status-row">
<div class="kyc-status-row__label">
<fa-icon icon="id-card"></fa-icon>
<span>PAN</span>
</div>
@if (panVerified) {
<div class="kyc-status-row__value">
<span class="kyc-status-row__id">{{ panNumber || '—' }}</span>
@if (panHolderName) {
<span class="kyc-status-row__name">{{ panHolderName }}</span>
}
</div>
<span class="kyc-pill kyc-pill--ok"><fa-icon icon="check-circle"></fa-icon> Verified</span>
} @else {
<span class="kyc-status-row__value kyc-status-row__value--missing">{{ panNumber || 'Not provided' }}</span>
<span class="kyc-pill kyc-pill--pending"><fa-icon icon="times-circle"></fa-icon> Pending</span>
}
</div>
<div class="kyc-status-row">
<div class="kyc-status-row__label">
<fa-icon icon="fingerprint"></fa-icon>
<span>Aadhaar</span>
</div>
@if (aadhaarVerified) {
<div class="kyc-status-row__value">
<span class="kyc-status-row__id">{{ aadhaarMasked || '—' }}</span>
@if (aadhaarHolderName) {
<span class="kyc-status-row__name">{{ aadhaarHolderName }}</span>
}
</div>
<span class="kyc-pill kyc-pill--ok"><fa-icon icon="check-circle"></fa-icon> Verified</span>
} @else {
<span class="kyc-status-row__value kyc-status-row__value--missing">{{
aadhaarMasked || 'Not provided'
}}</span>
<span class="kyc-pill kyc-pill--pending"><fa-icon icon="times-circle"></fa-icon> Pending</span>
}
</div>
</div>
</div>
<!-- Client basic info --> <!-- Client basic info -->
<div class="ckyc-info-grid"> <div class="ckyc-info-grid">
<div class="ckyc-info-item"> <div class="ckyc-info-item">
@@ -214,6 +214,179 @@
} }
} }
/* ===================== KYC Verification status ===================== */
.kyc-status-card {
border: 1px solid #e2e8f0;
border-radius: 14px;
background: #fff;
margin-bottom: 18px;
overflow: hidden;
box-shadow: 0 1px 3px rgb(15 23 42 / 4%);
&--complete {
border-color: #bbf7d0;
background: linear-gradient(180deg, #f0fdf4 0%, #fff 60%);
}
&__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 18px;
border-bottom: 1px solid #eef2f7;
}
&__title {
display: flex;
align-items: center;
gap: 10px;
font-size: 14px;
font-weight: 700;
color: #0f172a;
}
&__icon {
color: #2563eb;
font-size: 16px;
}
&--complete &__icon {
color: #16a34a;
}
&__badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 12px;
border-radius: 20px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.4px;
text-transform: uppercase;
fa-icon {
font-size: 11px;
}
&--ok {
background: #dcfce7;
color: #166534;
border: 1px solid #bbf7d0;
}
&--partial {
background: #fef3c7;
color: #92400e;
border: 1px solid #fde68a;
}
&--pending {
background: #fee2e2;
color: #991b1b;
border: 1px solid #fecaca;
}
}
&__body {
padding: 6px 18px 14px;
}
}
.kyc-status-row {
display: grid;
grid-template-columns: 140px 1fr auto;
align-items: center;
gap: 14px;
padding: 10px 0;
border-bottom: 1px dashed #eef2f7;
&:last-child {
border-bottom: none;
}
&__label {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
font-weight: 600;
color: #475569;
fa-icon {
color: #64748b;
font-size: 13px;
}
}
&__value {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
&--missing {
color: #94a3b8;
font-style: italic;
font-size: 13px;
}
}
&__id {
font-family: 'SF Mono', 'Cascadia Code', Consolas, monospace;
font-size: 13px;
font-weight: 600;
color: #0f172a;
word-break: break-all;
}
&__name {
font-size: 12px;
color: #64748b;
}
}
.kyc-pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: 20px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.3px;
white-space: nowrap;
fa-icon {
font-size: 11px;
}
&--ok {
background: #dcfce7;
color: #166534;
border: 1px solid #bbf7d0;
}
&--pending {
background: #f1f5f9;
color: #64748b;
border: 1px solid #e2e8f0;
}
}
@media (width <= 640px) {
.kyc-status-row {
grid-template-columns: 110px 1fr;
grid-template-rows: auto auto;
.kyc-pill {
grid-column: 2 / 3;
justify-self: start;
}
}
}
/* ===================== Client info grid ===================== */ /* ===================== Client info grid ===================== */
.ckyc-info-grid { .ckyc-info-grid {
display: grid; display: grid;
@@ -67,6 +67,18 @@ export class CkycTabComponent implements OnInit, OnDestroy {
ckycBatchId = ''; ckycBatchId = '';
checkingExisting = false; checkingExisting = false;
/** KYC verification status (Aadhaar + PAN) — independent of CERSAI sync */
panVerified = false;
aadhaarVerified = false;
panHolderName = '';
aadhaarHolderName = '';
panNumber = '';
aadhaarMasked = '';
get kycComplete(): boolean {
return this.panVerified && this.aadhaarVerified;
}
/** Pre-existing artefacts detected from client data */ /** Pre-existing artefacts detected from client data */
existing: Record<'ADDRESS_PROOF' | 'PHOTO_PROOF' | 'CLIENT_IMAGE' | 'CLIENT_SIGNATURE', ExistingArtefact> = { existing: Record<'ADDRESS_PROOF' | 'PHOTO_PROOF' | 'CLIENT_IMAGE' | 'CLIENT_SIGNATURE', ExistingArtefact> = {
ADDRESS_PROOF: { present: false }, ADDRESS_PROOF: { present: false },
@@ -170,6 +182,13 @@ export class CkycTabComponent implements OnInit, OnDestroy {
this.ckycNumber = data?.ckycNumber || ''; this.ckycNumber = data?.ckycNumber || '';
this.ckycUploadedOn = this.formatJavaDate(data?.ckycUploadedOn); this.ckycUploadedOn = this.formatJavaDate(data?.ckycUploadedOn);
this.ckycBatchId = data?.ckycBatchId || ''; this.ckycBatchId = data?.ckycBatchId || '';
this.panVerified = data?.panVerified === true || data?.panVerified === 'true' || data?.panVerified === 1;
this.aadhaarVerified =
data?.aadhaarVerified === true || data?.aadhaarVerified === 'true' || data?.aadhaarVerified === 1;
this.panHolderName = data?.panFullName || data?.panHolderName || '';
this.aadhaarHolderName = data?.aadhaarHolderName || '';
this.panNumber = data?.panNumber || '';
this.aadhaarMasked = data?.aadhaarNumberMasked || '';
}); });
} }
@@ -21,138 +21,10 @@ import { FileUploadComponent } from '../../../../shared/file-upload/file-upload.
import { MatIconButton } from '@angular/material/button'; import { MatIconButton } from '@angular/material/button';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { ClientsService } from '../../../clients.service'; import { ClientsService } from '../../../clients.service';
import { ProofType, normalizeProofType, groupProofsByCategory } from '../../../shared/kyc-proof-types';
interface ProofType {
id: string | number;
name: string;
code?: string;
sides?: number;
category?: string;
documentKeyLabel?: string;
documentKeyPlaceholder?: string;
requiresDate?: boolean;
info?: string;
}
type SectionKey = 'id' | 'address'; type SectionKey = 'id' | 'address';
/**
* Client-side enrichment for KYC proof types. Backend currently returns
* only { id, name, description (code), position, active, mandatory } — we
* derive sides / category / banner text / input labels from the code.
*/
const PROOF_META: Record<string, Partial<ProofType>> = {
PAN: {
sides: 1,
documentKeyLabel: 'PAN number',
documentKeyPlaceholder: 'e.g. ABCDE1234F',
info: 'PAN card — upload front side only (single copy required).'
},
AADHAAR: {
sides: 2,
documentKeyLabel: '12-digit Aadhaar number',
documentKeyPlaceholder: 'XXXX XXXX XXXX',
category: 'Government-issued ID',
info: 'Aadhaar — both front and back copies mandatory as per UIDAI guidelines.'
},
PASSPORT: {
sides: 2,
documentKeyLabel: 'Passport number',
documentKeyPlaceholder: 'e.g. A1234567',
category: 'Government-issued ID',
info: 'Passport — upload front (photo page) and back (last page with address).'
},
VOTER_ID: {
sides: 2,
documentKeyLabel: 'EPIC number',
documentKeyPlaceholder: 'Voter ID number',
category: 'Government-issued ID',
info: 'Voter ID (EPIC) — both sides required as address appears on the back.'
},
DRIVING_LICENSE: {
sides: 2,
documentKeyLabel: 'Driving licence number',
documentKeyPlaceholder: 'DL number',
category: 'Government-issued ID',
info: 'Driving licence — both sides required; vehicle class and validity are on the back.'
},
NREGA_JOB_CARD: {
sides: 1,
documentKeyLabel: 'NREGA job card number',
documentKeyPlaceholder: 'Job card number',
info: 'NREGA job card — ensure government authority signature is visible.'
},
NPR_LETTER: {
sides: 1,
documentKeyLabel: 'Reference / letter number',
documentKeyPlaceholder: 'NPR reference number',
info: 'NPR letter — upload full document.'
},
UTILITY_BILL: {
sides: 1,
category: 'Utility bills',
requiresDate: true,
info: 'Utility bill — must not be older than 3 months.'
},
ELECTRICITY_BILL: {
sides: 1,
category: 'Utility bills',
requiresDate: true,
info: 'Electricity bill — must not be older than 3 months.'
},
WATER_BILL: {
sides: 1,
category: 'Utility bills',
requiresDate: true,
info: 'Water bill — must not be older than 3 months.'
},
GAS_BILL: {
sides: 1,
category: 'Utility bills',
requiresDate: true,
info: 'Gas bill — must not be older than 3 months.'
},
INTERNET_BILL: {
sides: 1,
category: 'Utility bills',
requiresDate: true,
info: 'Internet bill — must not be older than 3 months.'
},
POSTPAID_MOBILE_BILL: {
sides: 1,
category: 'Utility bills',
requiresDate: true,
info: 'Postpaid mobile bill — must not be older than 3 months.'
},
BANK_STATEMENT: {
sides: 1,
category: 'Financial & other',
requiresDate: true,
info: 'Bank statement / passbook — not older than 3 months.'
},
RENT_AGREEMENT: {
sides: 1,
category: 'Financial & other',
info: 'Registered rent agreement — upload full document. Registration stamp must be visible.'
},
PROPERTY_TAX_RECEIPT: {
sides: 1,
category: 'Financial & other',
requiresDate: true,
info: 'Property tax receipt — latest receipt required.'
},
EMPLOYER_LETTER: {
sides: 1,
category: 'Financial & other',
info: 'Employer letter — must be on official letterhead with HR/management signature.'
},
PENSION_PAYMENT_ORDER: {
sides: 1,
category: 'Financial & other',
info: 'Pension payment order — upload full document issued by pension authority.'
}
};
@Component({ @Component({
selector: 'mifosx-upload-document-dialog', selector: 'mifosx-upload-document-dialog',
templateUrl: './upload-document-dialog.component.html', templateUrl: './upload-document-dialog.component.html',
@@ -245,9 +117,9 @@ export class UploadDocumentDialogComponent implements OnInit {
this.clientsService.getKycProofTypes().subscribe({ this.clientsService.getKycProofTypes().subscribe({
next: (res: any) => { next: (res: any) => {
const activeFilter = (p: any) => p?.active !== false; const activeFilter = (p: any) => p?.active !== false;
this.idProofs = (res?.idProofs || []).filter(activeFilter).map((p: any) => this.normalizeProof(p)); this.idProofs = (res?.idProofs || []).filter(activeFilter).map(normalizeProofType);
this.addressProofs = (res?.addressProofs || []).filter(activeFilter).map((p: any) => this.normalizeProof(p)); this.addressProofs = (res?.addressProofs || []).filter(activeFilter).map(normalizeProofType);
this.buildAddressGroups(); this.addressGroups = groupProofsByCategory(this.addressProofs);
this.loadingProofs = false; this.loadingProofs = false;
}, },
error: () => { error: () => {
@@ -256,37 +128,6 @@ export class UploadDocumentDialogComponent implements OnInit {
}); });
} }
private normalizeProof(p: any): ProofType {
const code = (p.code || p.description || '').toString().toUpperCase();
const meta = PROOF_META[code] || {};
return {
id: p.id,
name: p.name,
code,
sides: p.sides || meta.sides || 1,
category: p.category || meta.category || 'Other',
documentKeyLabel: p.documentKeyLabel || meta.documentKeyLabel || 'Document number',
documentKeyPlaceholder: p.documentKeyPlaceholder || meta.documentKeyPlaceholder || '',
requiresDate: p.requiresDate ?? meta.requiresDate ?? false,
info: p.info || meta.info || ''
};
}
private buildAddressGroups(): void {
const map = new Map<string, ProofType[]>();
this.addressProofs.forEach((p) => {
const key = p.category || 'Other';
if (!map.has(key)) map.set(key, []);
map.get(key).push(p);
});
this.addressGroups = Array.from(map.entries()).map(
([
label,
items
]) => ({ label, items })
);
}
onIdProofChange(proofId: any): void { onIdProofChange(proofId: any): void {
this.selectedIdProof = this.idProofs.find((p) => String(p.id) === String(proofId)) || null; this.selectedIdProof = this.idProofs.find((p) => String(p.id) === String(proofId)) || null;
this.idFiles = { front: null, back: null }; this.idFiles = { front: null, back: null };
@@ -294,7 +294,7 @@ export class NomineeTabComponent implements OnInit {
} }
this.searching = true; this.searching = true;
this.searchTimer = setTimeout(() => { this.searchTimer = setTimeout(() => {
this.clientsService.searchByText(text, 0, 8).subscribe({ this.clientsService.searchByText(text, 0, 8, '', '', true).subscribe({
next: (data: any) => { next: (data: any) => {
this.searchResults = data?.content || []; this.searchResults = data?.content || [];
this.searching = false; this.searching = false;
@@ -412,7 +412,7 @@ export class NomineeTabComponent implements OnInit {
} }
this.guardianSearching = true; this.guardianSearching = true;
this.guardianSearchTimer = setTimeout(() => { this.guardianSearchTimer = setTimeout(() => {
this.clientsService.searchByText(text, 0, 8).subscribe({ this.clientsService.searchByText(text, 0, 8, '', '', true).subscribe({
next: (data: any) => { next: (data: any) => {
this.guardianSearchResults = data?.content || []; this.guardianSearchResults = data?.content || [];
this.guardianSearching = false; this.guardianSearching = false;
+13 -4
View File
@@ -576,11 +576,20 @@ export class ClientsService {
return this.http.get(`/clients/${clientId}/collaterals/template`); return this.http.get(`/clients/${clientId}/collaterals/template`);
} }
searchByText(text: string, page: number, pageSize: number, sortAttribute: string = '', sortDirection: string = '') { searchByText(
text: string,
page: number,
pageSize: number,
sortAttribute: string = '',
sortDirection: string = '',
activeOnly: boolean = false
) {
const innerRequest: any = { text };
if (activeOnly) {
innerRequest.activeOnly = true;
}
let request: any = { let request: any = {
request: { request: innerRequest,
text
},
page, page,
size: pageSize size: pageSize
}; };
@@ -72,9 +72,9 @@ export class CreateClientComponent {
@ViewChild(ClientGeneralStepComponent) clientGeneralStep: ClientGeneralStepComponent; @ViewChild(ClientGeneralStepComponent) clientGeneralStep: ClientGeneralStepComponent;
@ViewChild('clientAddress') clientAddressStep: ClientAddressStepComponent; @ViewChild('clientAddress') clientAddressStep: ClientAddressStepComponent;
@ViewChild('clientIndiaAddress') clientIndiaAddressStep: ClientIndiaAddressStepComponent; @ViewChild('clientIndiaAddress') clientIndiaAddressStep: ClientIndiaAddressStepComponent;
@ViewChild('clientEntityKyc') clientEntityKycStep: ClientEntityKycStepComponent; @ViewChild(ClientEntityKycStepComponent) clientEntityKycStep: ClientEntityKycStepComponent;
@ViewChild('clientKyc') clientKycStep: ClientKycStepComponent; @ViewChild('clientKyc') clientKycStep: ClientKycStepComponent;
@ViewChild('clientFamilyMembers') clientFamilyMembersStep: ClientFamilyMembersStepComponent; @ViewChild(ClientFamilyMembersStepComponent) clientFamilyMembersStep: ClientFamilyMembersStepComponent;
@ViewChildren('dtclient') clientDatatables: QueryList<ClientDatatableStepComponent>; @ViewChildren('dtclient') clientDatatables: QueryList<ClientDatatableStepComponent>;
datatables: any = []; datatables: any = [];
@@ -250,6 +250,13 @@ export class CreateClientComponent {
// KYC tab // KYC tab
const kycTabIndex = 1; const kycTabIndex = 1;
if (tabIndex === kycTabIndex) { if (tabIndex === kycTabIndex) {
// Photograph + Signature are mandatory before leaving the KYC tab
if (this.clientKycStep && !this.clientKycStep.photoSigComplete) {
this.clientKycStep.markPhotoSigTouched();
this.validationError = 'Photograph and Signature are required before continuing.';
return;
}
this.validationError = '';
// Save KYC // Save KYC
if (this.clientKycStep?.hasKycData && !this.clientKycStep.kycId) { if (this.clientKycStep?.hasKycData && !this.clientKycStep.kycId) {
this.clientsService.saveClientKyc(clientId, this.clientKycStep.kycData).subscribe({ error: () => {} }); this.clientsService.saveClientKyc(clientId, this.clientKycStep.kycData).subscribe({ error: () => {} });
@@ -265,6 +272,8 @@ export class CreateClientComponent {
.uploadClientSignatureImage(clientId, this.clientKycStep.signatureFile) .uploadClientSignatureImage(clientId, this.clientKycStep.signatureFile)
.subscribe({ error: () => {} }); .subscribe({ error: () => {} });
} }
// Persist the KYC document picker selection + uploaded files as a client identifier
this.saveKycDocProof(clientId);
return; return;
} }
@@ -349,6 +358,14 @@ export class CreateClientComponent {
validationError = ''; validationError = '';
submit() { submit() {
// Validate: photograph and signature are mandatory
if (this.clientKycStep && !this.clientKycStep.photoSigComplete) {
this.clientKycStep.markPhotoSigTouched();
this.validationError = 'Photograph and Signature are required before submitting.';
// Switch to KYC tab
this.selectedTabIndex = 1;
return;
}
// Validate: at least one family member required (Individual clients) // Validate: at least one family member required (Individual clients)
if (this.legalFormType === 1 && this.clientFamilyMembersStep && !this.clientFamilyMembersStep.hasFamilyMembers) { if (this.legalFormType === 1 && this.clientFamilyMembersStep && !this.clientFamilyMembersStep.hasFamilyMembers) {
this.validationError = 'At least one family member is required. Please add a family member before submitting.'; this.validationError = 'At least one family member is required. Please add a family member before submitting.';
@@ -425,6 +442,9 @@ export class CreateClientComponent {
this.saveClientFamilyMembers(clientId); this.saveClientFamilyMembers(clientId);
} }
// Persist the KYC document picker selection + uploaded files as a client identifier
this.saveKycDocProof(clientId);
// Save KYC only if it wasn't already created during the KYC step // Save KYC only if it wasn't already created during the KYC step
if (this.clientKycStep?.hasKycData && !this.clientKycStep.kycId) { if (this.clientKycStep?.hasKycData && !this.clientKycStep.kycId) {
this.clientsService.saveClientKyc(clientId, this.clientKycStep.kycData).subscribe({ this.clientsService.saveClientKyc(clientId, this.clientKycStep.kycData).subscribe({
@@ -514,6 +534,72 @@ export class CreateClientComponent {
}, },
error: () => {} error: () => {}
}); });
// Persist the selected proof of address as a client identifier (with attached files)
this.saveAddressProof(clientId);
}
/**
* Save the address-proof picked on the permanent tab as a client identifier
* + upload each selected file as a document attached to that identifier.
* Mirrors identities-tab.createIdentifierWithDocs.
*/
private saveAddressProof(clientId: string) {
const payload = this.clientIndiaAddressStep?.addressProofPayload;
if (!payload?.proofId) return;
const proofName = payload.proof?.name || 'Address Proof';
const identifierData: any = {
documentTypeId: payload.proofId,
status: 'Active',
documentKey: payload.documentKey || `${proofName} - ${clientId}`,
description: payload.issueDate ? `Issue date: ${payload.issueDate}` : ''
};
this.clientsService.addClientIdentifier(clientId, identifierData).subscribe({
next: (res: any) => {
const identifierId = res?.resourceId;
if (!identifierId) return;
for (const entry of payload.files) {
const formData = new FormData();
formData.append('name', `${proofName} — ${entry.slot}`);
formData.append('file', entry.file);
this.clientsService.uploadClientIdentifierDocument(identifierId, formData).subscribe({ error: () => {} });
}
},
error: () => {}
});
}
/**
* Save the KYC document picked on the KYC tab as a client identifier
* + upload each selected file as a document attached to that identifier.
*/
private saveKycDocProof(clientId: string) {
const payload = this.clientKycStep?.kycDocPayload;
if (!payload?.proofId || !payload?.files?.length) return;
const proofName = payload.proof?.name || 'KYC Document';
const identifierData: any = {
documentTypeId: payload.proofId,
status: 'Active',
documentKey: payload.documentKey || `${proofName} - ${clientId}`,
description: 'KYC document'
};
this.clientsService.addClientIdentifier(clientId, identifierData).subscribe({
next: (res: any) => {
const identifierId = res?.resourceId;
if (!identifierId) return;
for (const entry of payload.files) {
const formData = new FormData();
formData.append('name', `${proofName} — ${entry.slot}`);
formData.append('file', entry.file);
this.clientsService.uploadClientIdentifierDocument(identifierId, formData).subscribe({ error: () => {} });
}
},
error: () => {}
});
} }
/** /**
@@ -67,8 +67,8 @@
<div class="rekyc-alert"> <div class="rekyc-alert">
<mat-icon class="rekyc-alert__icon">warning</mat-icon> <mat-icon class="rekyc-alert__icon">warning</mat-icon>
<div class="rekyc-alert__text"> <div class="rekyc-alert__text">
<strong>Re-KYC Required:</strong> Date of birth was changed. Please re-verify PAN and update the remarks <strong>Re-KYC Required:</strong> Client name or date of birth was changed. Please re-verify PAN and
below before proceeding. update the remarks below before proceeding.
</div> </div>
</div> </div>
} }
@@ -180,6 +180,8 @@
[addressData]="clientIndiaAddressStep?.addressData" [addressData]="clientIndiaAddressStep?.addressData"
[guardian]="clientGeneralStep?.selectedGuardian" [guardian]="clientGeneralStep?.selectedGuardian"
[guardianRelation]="clientGeneralStep?.createClientForm?.get('guardianDetails.guardianRelation')?.value || ''" [guardianRelation]="clientGeneralStep?.createClientForm?.get('guardianDetails.guardianRelation')?.value || ''"
[entityClassificationData]="clientEntityKycStep?.classificationData"
[familyMembers]="clientFamilyMembersStep?.clientFamilyMembers || []"
[maritalStatusId]="clientGeneralStep?.createClientForm?.get('maritalStatusId')?.value" [maritalStatusId]="clientGeneralStep?.createClientForm?.get('maritalStatusId')?.value"
[maritalStatusOptions]="clientGeneralStep?.maritalStatusOptions || []" [maritalStatusOptions]="clientGeneralStep?.maritalStatusOptions || []"
[educationLevelOptions]="clientGeneralStep?.educationLevelOptions || []" [educationLevelOptions]="clientGeneralStep?.educationLevelOptions || []"
@@ -69,9 +69,9 @@ export class EditClientComponent {
@ViewChild(ClientGeneralStepComponent) clientGeneralStep: ClientGeneralStepComponent; @ViewChild(ClientGeneralStepComponent) clientGeneralStep: ClientGeneralStepComponent;
@ViewChild('clientAddress') clientAddressStep: ClientAddressStepComponent; @ViewChild('clientAddress') clientAddressStep: ClientAddressStepComponent;
@ViewChild('clientIndiaAddress') clientIndiaAddressStep: ClientIndiaAddressStepComponent; @ViewChild('clientIndiaAddress') clientIndiaAddressStep: ClientIndiaAddressStepComponent;
@ViewChild('clientEntityKyc') clientEntityKycStep: ClientEntityKycStepComponent; @ViewChild(ClientEntityKycStepComponent) clientEntityKycStep: ClientEntityKycStepComponent;
@ViewChild('clientKyc') clientKycStep: ClientKycStepComponent; @ViewChild('clientKyc') clientKycStep: ClientKycStepComponent;
@ViewChild('clientFamilyMembers') clientFamilyMembersStep: ClientFamilyMembersStepComponent; @ViewChild(ClientFamilyMembersStepComponent) clientFamilyMembersStep: ClientFamilyMembersStepComponent;
@ViewChildren('dtclient') clientDatatables: QueryList<ClientDatatableStepComponent>; @ViewChildren('dtclient') clientDatatables: QueryList<ClientDatatableStepComponent>;
datatables: any = []; datatables: any = [];
@@ -184,13 +184,13 @@ export class EditClientComponent {
let dialogContext = 'Are you sure you want to save the changes'; let dialogContext = 'Are you sure you want to save the changes';
let dialogType = 'basic'; let dialogType = 'basic';
// Check if DOB changed on the General tab — warn about re-KYC // Check if name or DOB changed on the General tab — warn about re-KYC
if (this.selectedTabIndex === 0) { if (this.selectedTabIndex === 0) {
const changes = this.detectSensitiveFieldChanges(this.clientDataAndTemplate); const changes = this.detectSensitiveFieldChanges(this.clientDataAndTemplate);
if (changes.dobChanged) { if (changes.nameChanged || changes.dobChanged) {
heading = 'Re-KYC Required'; heading = 'Re-KYC Required';
dialogContext = dialogContext =
'Date of birth has been changed. This will require PAN re-verification in the KYC section. A remark will be added automatically. Do you want to proceed'; 'Client name or date of birth has been changed. This will require PAN re-verification in the KYC section. A remark will be added automatically. Do you want to proceed';
dialogType = 'warn'; dialogType = 'warn';
} }
} }
@@ -232,11 +232,11 @@ export class EditClientComponent {
// Log mobile/email changes as client notes for risk audit // Log mobile/email changes as client notes for risk audit
this.logFieldChangeNotes(changedFields); this.logFieldChangeNotes(changedFields);
// If DOB changed, flag re-KYC requirement and reset PAN verification // If name or DOB changed, flag re-KYC requirement and reset PAN verification
if (changedFields.dobChanged) { if (changedFields.nameChanged || changedFields.dobChanged) {
this.dobChanged = true; this.dobChanged = true;
this.tabEditMode[1] = true; // auto-enable KYC tab for re-verification this.tabEditMode[1] = true; // auto-enable KYC tab for re-verification
this.flagReKycRequired(changedFields.oldDob, changedFields.newDob); this.flagReKycRequired(changedFields);
} }
// Update original data snapshot so subsequent edits compare against latest // Update original data snapshot so subsequent edits compare against latest
@@ -249,6 +249,13 @@ export class EditClientComponent {
if (changedFields.dobChanged) { if (changedFields.dobChanged) {
originalData.dateOfBirth = changedFields.newDob; originalData.dateOfBirth = changedFields.newDob;
} }
if (changedFields.nameChanged) {
const form = this.clientGeneralForm;
originalData.firstname = form?.get('firstname')?.value ?? originalData.firstname;
originalData.middlename = form?.get('middlename')?.value ?? originalData.middlename;
originalData.lastname = form?.get('lastname')?.value ?? originalData.lastname;
originalData.fullname = form?.get('fullname')?.value ?? originalData.fullname;
}
this.selectedTabIndex = this.selectedTabIndex + 1; this.selectedTabIndex = this.selectedTabIndex + 1;
}, },
@@ -261,6 +268,11 @@ export class EditClientComponent {
// KYC tab (always index 1) // KYC tab (always index 1)
if (tabIndex === 1) { if (tabIndex === 1) {
// Photograph + Signature are mandatory before leaving the KYC tab
if (this.clientKycStep && !this.clientKycStep.photoSigComplete) {
this.clientKycStep.markPhotoSigTouched();
return;
}
this.savingClient = true; this.savingClient = true;
// Upload photo if selected // Upload photo if selected
@@ -317,6 +329,8 @@ export class EditClientComponent {
if (!kycId && res?.resourceId) { if (!kycId && res?.resourceId) {
this.clientKycStep.kycId = res.resourceId.toString(); this.clientKycStep.kycId = res.resourceId.toString();
} }
// Persist KYC document picker selection + uploaded files as a client identifier
this.saveKycDocProof(this.clientId);
this.selectedTabIndex = this.selectedTabIndex + 1; this.selectedTabIndex = this.selectedTabIndex + 1;
}, },
error: () => { error: () => {
@@ -363,7 +377,7 @@ export class EditClientComponent {
} }
/** /**
* Compare current form values with original data to detect changes in DOB, mobile, email. * Compare current form values with original data to detect changes in name, DOB, mobile, email.
*/ */
private detectSensitiveFieldChanges(originalData: any) { private detectSensitiveFieldChanges(originalData: any) {
const form = this.clientGeneralForm; const form = this.clientGeneralForm;
@@ -381,10 +395,38 @@ export class EditClientComponent {
? oldDobRaw.toISOString().split('T')[0] ? oldDobRaw.toISOString().split('T')[0]
: oldDobRaw || ''; : oldDobRaw || '';
const buildName = (parts: { firstname?: string; middlename?: string; lastname?: string; fullname?: string }) =>
(
parts.fullname ||
[
parts.firstname,
parts.middlename,
parts.lastname
]
.filter((p) => p && p.trim())
.join(' ')
).trim();
const newName = buildName({
firstname: form?.get('firstname')?.value || '',
middlename: form?.get('middlename')?.value || '',
lastname: form?.get('lastname')?.value || '',
fullname: form?.get('fullname')?.value || ''
});
const oldName = buildName({
firstname: originalData.firstname || '',
middlename: originalData.middlename || '',
lastname: originalData.lastname || '',
fullname: originalData.fullname || ''
});
return { return {
nameChanged: newName !== oldName,
mobileChanged: newMobile !== oldMobile, mobileChanged: newMobile !== oldMobile,
emailChanged: newEmail !== oldEmail, emailChanged: newEmail !== oldEmail,
dobChanged: newDob !== oldDob, dobChanged: newDob !== oldDob,
oldName,
newName,
oldMobile, oldMobile,
newMobile, newMobile,
oldEmail, oldEmail,
@@ -412,22 +454,38 @@ export class EditClientComponent {
} }
/** /**
* When DOB changes, reset PAN verification in KYC step and pre-fill remark for re-KYC. * When name or DOB changes, reset PAN verification in KYC step and pre-fill remark for re-KYC.
*/ */
private flagReKycRequired(oldDob: string, newDob: string) { private flagReKycRequired(changes: {
nameChanged: boolean;
dobChanged: boolean;
oldName: string;
newName: string;
oldDob: string;
newDob: string;
}) {
const reasons: string[] = [];
if (changes.nameChanged) {
reasons.push(`Client name changed from "${changes.oldName}" to "${changes.newName}"`);
}
if (changes.dobChanged) {
reasons.push(`Date of birth changed from "${changes.oldDob}" to "${changes.newDob}"`);
}
const reasonText = reasons.join('; ');
// Reset PAN verification status so user must re-verify // Reset PAN verification status so user must re-verify
if (this.clientKycStep) { if (this.clientKycStep) {
this.clientKycStep.panVerifyStatus = 'NONE'; this.clientKycStep.panVerifyStatus = 'NONE';
this.clientKycStep.panVerifyName = ''; this.clientKycStep.panVerifyName = '';
this.clientKycStep.panVerifiedOn = ''; this.clientKycStep.panVerifiedOn = '';
// Pre-fill remarks with re-KYC note // Pre-fill remarks with re-KYC note
const remarkText = `[Re-KYC Required] Date of birth changed from ${oldDob} to ${newDob}. PAN re-verification is mandatory.`; const remarkText = `[Re-KYC Required] ${reasonText}. PAN re-verification is mandatory.`;
this.clientKycStep.kycForm.get('remarks')?.setValue(remarkText); this.clientKycStep.kycForm.get('remarks')?.setValue(remarkText);
} }
// Also log DOB change as an audit note // Also log the change as an audit note
const timestamp = new Date().toISOString(); const timestamp = new Date().toISOString();
const note = `[AUDIT] Date of birth changed from "${oldDob}" to "${newDob}" on ${timestamp}. Re-KYC (PAN re-verification) required.`; const note = `[AUDIT] ${reasonText} on ${timestamp}. Re-KYC (PAN re-verification) required.`;
this.clientsService.createClientNote(this.clientId, { note }).subscribe({ error: () => {} }); this.clientsService.createClientNote(this.clientId, { note }).subscribe({ error: () => {} });
} }
@@ -439,6 +497,13 @@ export class EditClientComponent {
* Submits the edit client form. * Submits the edit client form.
*/ */
submit() { submit() {
// Photograph + Signature are mandatory before submitting
if (this.clientKycStep && !this.clientKycStep.photoSigComplete) {
this.clientKycStep.markPhotoSigTouched();
this.selectedTabIndex = 1;
return;
}
const locale = this.settingsService.language.code; const locale = this.settingsService.language.code;
const dateFormat = this.settingsService.dateFormat; const dateFormat = this.settingsService.dateFormat;
const clientData = { const clientData = {
@@ -496,10 +561,14 @@ export class EditClientComponent {
kycObs = this.clientsService.saveClientKyc(this.clientId, kycData); kycObs = this.clientsService.saveClientKyc(this.clientId, kycData);
} }
kycObs.subscribe({ kycObs.subscribe({
next: () => navigateBack(), next: () => {
this.saveKycDocProof(this.clientId);
navigateBack();
},
error: () => navigateBack() error: () => navigateBack()
}); });
} else { } else {
this.saveKycDocProof(this.clientId);
navigateBack(); navigateBack();
} }
}, },
@@ -580,6 +649,72 @@ export class EditClientComponent {
}, },
error: () => {} error: () => {}
}); });
// Persist the selected proof of address as a client identifier (with attached files)
this.saveAddressProof(clientId);
}
/**
* Save the address-proof picked on the permanent tab as a client identifier
* + upload each selected file as a document attached to that identifier.
* Mirrors identities-tab.createIdentifierWithDocs.
*/
private saveAddressProof(clientId: string) {
const payload = this.clientIndiaAddressStep?.addressProofPayload;
if (!payload?.proofId) return;
const proofName = payload.proof?.name || 'Address Proof';
const identifierData: any = {
documentTypeId: payload.proofId,
status: 'Active',
documentKey: payload.documentKey || `${proofName} - ${clientId}`,
description: payload.issueDate ? `Issue date: ${payload.issueDate}` : ''
};
this.clientsService.addClientIdentifier(clientId, identifierData).subscribe({
next: (res: any) => {
const identifierId = res?.resourceId;
if (!identifierId) return;
for (const entry of payload.files) {
const formData = new FormData();
formData.append('name', `${proofName} — ${entry.slot}`);
formData.append('file', entry.file);
this.clientsService.uploadClientIdentifierDocument(identifierId, formData).subscribe({ error: () => {} });
}
},
error: () => {}
});
}
/**
* Save the KYC document picked on the KYC tab as a client identifier
* + upload each selected file as a document attached to that identifier.
*/
private saveKycDocProof(clientId: string) {
const payload = this.clientKycStep?.kycDocPayload;
if (!payload?.proofId || !payload?.files?.length) return;
const proofName = payload.proof?.name || 'KYC Document';
const identifierData: any = {
documentTypeId: payload.proofId,
status: 'Active',
documentKey: payload.documentKey || `${proofName} - ${clientId}`,
description: 'KYC document'
};
this.clientsService.addClientIdentifier(clientId, identifierData).subscribe({
next: (res: any) => {
const identifierId = res?.resourceId;
if (!identifierId) return;
for (const entry of payload.files) {
const formData = new FormData();
formData.append('name', `${proofName} — ${entry.slot}`);
formData.append('file', entry.file);
this.clientsService.uploadClientIdentifierDocument(identifierId, formData).subscribe({ error: () => {} });
}
},
error: () => {}
});
} }
private saveClientFamilyMembers(clientId: string) { private saveClientFamilyMembers(clientId: string) {
+167
View File
@@ -0,0 +1,167 @@
/**
* 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/.
*/
export interface ProofType {
id: string | number;
name: string;
code?: string;
sides?: number;
category?: string;
documentKeyLabel?: string;
documentKeyPlaceholder?: string;
requiresDate?: boolean;
info?: string;
}
/**
* Client-side enrichment for KYC proof types. Backend currently returns
* only { id, name, description (code), position, active, mandatory } — we
* derive sides / category / banner text / input labels from the code.
*/
export const PROOF_META: Record<string, Partial<ProofType>> = {
PAN: {
sides: 1,
documentKeyLabel: 'PAN number',
documentKeyPlaceholder: 'e.g. ABCDE1234F',
info: 'PAN card — upload front side only (single copy required).'
},
AADHAAR: {
sides: 2,
documentKeyLabel: '12-digit Aadhaar number',
documentKeyPlaceholder: 'XXXX XXXX XXXX',
category: 'Government-issued ID',
info: 'Aadhaar — both front and back copies mandatory as per UIDAI guidelines.'
},
PASSPORT: {
sides: 2,
documentKeyLabel: 'Passport number',
documentKeyPlaceholder: 'e.g. A1234567',
category: 'Government-issued ID',
info: 'Passport — upload front (photo page) and back (last page with address).'
},
VOTER_ID: {
sides: 2,
documentKeyLabel: 'EPIC number',
documentKeyPlaceholder: 'Voter ID number',
category: 'Government-issued ID',
info: 'Voter ID (EPIC) — both sides required as address appears on the back.'
},
DRIVING_LICENSE: {
sides: 2,
documentKeyLabel: 'Driving licence number',
documentKeyPlaceholder: 'DL number',
category: 'Government-issued ID',
info: 'Driving licence — both sides required; vehicle class and validity are on the back.'
},
NREGA_JOB_CARD: {
sides: 1,
documentKeyLabel: 'NREGA job card number',
documentKeyPlaceholder: 'Job card number',
info: 'NREGA job card — ensure government authority signature is visible.'
},
NPR_LETTER: {
sides: 1,
documentKeyLabel: 'Reference / letter number',
documentKeyPlaceholder: 'NPR reference number',
info: 'NPR letter — upload full document.'
},
UTILITY_BILL: {
sides: 1,
category: 'Utility bills',
requiresDate: true,
info: 'Utility bill — must not be older than 3 months.'
},
ELECTRICITY_BILL: {
sides: 1,
category: 'Utility bills',
requiresDate: true,
info: 'Electricity bill — must not be older than 3 months.'
},
WATER_BILL: {
sides: 1,
category: 'Utility bills',
requiresDate: true,
info: 'Water bill — must not be older than 3 months.'
},
GAS_BILL: {
sides: 1,
category: 'Utility bills',
requiresDate: true,
info: 'Gas bill — must not be older than 3 months.'
},
INTERNET_BILL: {
sides: 1,
category: 'Utility bills',
requiresDate: true,
info: 'Internet bill — must not be older than 3 months.'
},
POSTPAID_MOBILE_BILL: {
sides: 1,
category: 'Utility bills',
requiresDate: true,
info: 'Postpaid mobile bill — must not be older than 3 months.'
},
BANK_STATEMENT: {
sides: 1,
category: 'Financial & other',
requiresDate: true,
info: 'Bank statement / passbook — not older than 3 months.'
},
RENT_AGREEMENT: {
sides: 1,
category: 'Financial & other',
info: 'Registered rent agreement — upload full document. Registration stamp must be visible.'
},
PROPERTY_TAX_RECEIPT: {
sides: 1,
category: 'Financial & other',
requiresDate: true,
info: 'Property tax receipt — latest receipt required.'
},
EMPLOYER_LETTER: {
sides: 1,
category: 'Financial & other',
info: 'Employer letter — must be on official letterhead with HR/management signature.'
},
PENSION_PAYMENT_ORDER: {
sides: 1,
category: 'Financial & other',
info: 'Pension payment order — upload full document issued by pension authority.'
}
};
export function normalizeProofType(p: any): ProofType {
const code = (p.code || p.description || '').toString().toUpperCase();
const meta = PROOF_META[code] || {};
return {
id: p.id,
name: p.name,
code,
sides: p.sides || meta.sides || 1,
category: p.category || meta.category || 'Other',
documentKeyLabel: p.documentKeyLabel || meta.documentKeyLabel || 'Document number',
documentKeyPlaceholder: p.documentKeyPlaceholder || meta.documentKeyPlaceholder || '',
requiresDate: p.requiresDate ?? meta.requiresDate ?? false,
info: p.info || meta.info || ''
};
}
export function groupProofsByCategory(proofs: ProofType[]): { label: string; items: ProofType[] }[] {
const map = new Map<string, ProofType[]>();
proofs.forEach((p) => {
const key = p.category || 'Other';
if (!map.has(key)) map.set(key, []);
map.get(key)!.push(p);
});
return Array.from(map.entries()).map(
([
label,
items
]) => ({ label, items })
);
}
@@ -0,0 +1,102 @@
/**
* 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 { MatDialog } from '@angular/material/dialog';
import { ImageCropperDialogComponent } from '../clients-view/custom-dialogs/image-cropper-dialog/image-cropper-dialog.component';
@Injectable({ providedIn: 'root' })
export class KycUploadHelperService {
private dialog = inject(MatDialog);
/**
* If the picked file is an image, open the cropper dialog and resolve with
* the cropped File (or null if cancelled). Non-image files (PDFs) are
* passed through unchanged.
*/
pickWithCrop(file: File, title: string): Promise<File | null> {
if (!file.type.startsWith('image/')) {
return Promise.resolve(file);
}
return new Promise((resolve) => {
const ref = this.dialog.open(ImageCropperDialogComponent, {
width: '720px',
maxWidth: '95vw',
data: { file, title }
});
ref.afterClosed().subscribe((cropped: File | null) => resolve(cropped || null));
});
}
/**
* Stack two images (front above back) into one PNG. Resolves with both
* the new File (suitable for upload) and a data-URL preview for the UI.
* Returns null if either side is not an image (e.g. one is a PDF).
*/
mergeFrontBack(front: File, back: File, name: string): Promise<{ file: File; preview: string } | null> {
if (!front.type.startsWith('image/') || !back.type.startsWith('image/')) {
return Promise.resolve(null);
}
return Promise.all([
this.fileToImage(front),
this.fileToImage(back)
]).then(
([
fImg,
bImg
]) => {
const targetWidth = Math.max(fImg.width, bImg.width);
const fScale = targetWidth / fImg.width;
const bScale = targetWidth / bImg.width;
const fH = fImg.height * fScale;
const bH = bImg.height * bScale;
const gap = 20;
const totalH = Math.round(fH + bH + gap);
const canvas = document.createElement('canvas');
canvas.width = targetWidth;
canvas.height = totalH;
const ctx = canvas.getContext('2d');
if (!ctx) return null;
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(fImg, 0, 0, targetWidth, fH);
ctx.drawImage(bImg, 0, fH + gap, targetWidth, bH);
return new Promise<{ file: File; preview: string } | null>((resolve) => {
canvas.toBlob((blob) => {
if (!blob) {
resolve(null);
return;
}
const file = new File([blob], `${name}-combined.png`, { type: 'image/png' });
const preview = canvas.toDataURL('image/png');
resolve({ file, preview });
}, 'image/png');
});
}
);
}
private fileToImage(file: File): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const url = URL.createObjectURL(file);
const img = new Image();
img.onload = () => {
URL.revokeObjectURL(url);
resolve(img);
};
img.onerror = () => {
URL.revokeObjectURL(url);
reject(new Error('image load failed'));
};
img.src = url;
});
}
}
@@ -23,6 +23,9 @@ export interface Credentials {
roles: any; roles: any;
userId: number; userId: number;
username: string; username: string;
/** Optional contact details — populated when the auth response includes them. */
email?: string;
mobileNo?: string;
shouldRenewPassword: boolean; shouldRenewPassword: boolean;
rememberMe?: boolean; rememberMe?: boolean;
} }
+3
View File
@@ -220,6 +220,9 @@
<button mat-menu-item routerLink="/templates" tabindex="0"> <button mat-menu-item routerLink="/templates" tabindex="0">
<i class="fa fa-file-alt menu-item-icon"></i> Templates <i class="fa fa-file-alt menu-item-icon"></i> Templates
</button> </button>
<button mat-menu-item routerLink="/otp-audit-logs" tabindex="0">
<i class="fa fa-key menu-item-icon"></i> OTP Audit Logs
</button>
</mat-menu> </mat-menu>
<!-- Sub Ledger Mega Menu --> <!-- Sub Ledger Mega Menu -->
@@ -342,6 +342,12 @@
</mat-icon> </mat-icon>
<span>{{ 'labels.menus.Profile' | translate }}</span> <span>{{ 'labels.menus.Profile' | translate }}</span>
</button> </button>
<button mat-menu-item [routerLink]="['/access-control']" tabindex="0">
<mat-icon>
<fa-icon icon="shield-alt" size="sm"></fa-icon>
</mat-icon>
<span>Access Control</span>
</button>
<button mat-menu-item [routerLink]="['/settings']" tabindex="0"> <button mat-menu-item [routerLink]="['/settings']" tabindex="0">
<mat-icon> <mat-icon>
<fa-icon icon="cog" size="sm"></fa-icon> <fa-icon icon="cog" size="sm"></fa-icon>
+1 -3
View File
@@ -253,9 +253,7 @@
@if (resetPassword) { @if (resetPassword) {
<mifosx-reset-password></mifosx-reset-password> <mifosx-reset-password></mifosx-reset-password>
} }
@if (twoFactorAuthenticationRequired) { <!-- 2FA opens as a non-dismissable dialog from login.component.ts -->
<mifosx-two-factor-authentication></mifosx-two-factor-authentication>
}
<div class="right-footer">RBI Licensed · PCI-DSS Compliant · ISO 27001 Certified</div> <div class="right-footer">RBI Licensed · PCI-DSS Compliant · ISO 27001 Certified</div>
</div> </div>
+31
View File
@@ -43,6 +43,7 @@ import { TenantSelectorComponent } from '../shared/tenant-selector/tenant-select
import { LoginFormComponent } from './login-form/login-form.component'; import { LoginFormComponent } from './login-form/login-form.component';
import { ResetPasswordComponent } from './reset-password/reset-password.component'; import { ResetPasswordComponent } from './reset-password/reset-password.component';
import { TwoFactorAuthenticationComponent } from './two-factor-authentication/two-factor-authentication.component'; import { TwoFactorAuthenticationComponent } from './two-factor-authentication/two-factor-authentication.component';
import { MatDialog, MatDialogRef } from '@angular/material/dialog';
import { MatMenuTrigger, MatMenu, MatMenuItem } from '@angular/material/menu'; import { MatMenuTrigger, MatMenu, MatMenuItem } from '@angular/material/menu';
import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
@@ -216,6 +217,8 @@ export class LoginComponent implements OnInit, OnDestroy {
private alertService = inject(AlertService); private alertService = inject(AlertService);
private settingsService = inject(SettingsService); private settingsService = inject(SettingsService);
private themingService = inject(ThemingService); private themingService = inject(ThemingService);
private dialog = inject(MatDialog);
private twoFactorDialogRef: MatDialogRef<TwoFactorAuthenticationComponent> | null = null;
private router = inject(Router); private router = inject(Router);
private versionService = inject(VersionService); private versionService = inject(VersionService);
@@ -270,13 +273,16 @@ export class LoginComponent implements OnInit, OnDestroy {
const alertType = alertEvent.type; const alertType = alertEvent.type;
if (alertType === 'Password Expired') { if (alertType === 'Password Expired') {
this.twoFactorAuthenticationRequired = false; this.twoFactorAuthenticationRequired = false;
this.closeTwoFactorDialog();
this.resetPassword = true; this.resetPassword = true;
} else if (alertType === 'Two Factor Authentication Required') { } else if (alertType === 'Two Factor Authentication Required') {
this.resetPassword = false; this.resetPassword = false;
this.twoFactorAuthenticationRequired = true; this.twoFactorAuthenticationRequired = true;
this.openTwoFactorDialog();
} else if (alertType === 'Authentication Success') { } else if (alertType === 'Authentication Success') {
this.resetPassword = false; this.resetPassword = false;
this.twoFactorAuthenticationRequired = false; this.twoFactorAuthenticationRequired = false;
this.closeTwoFactorDialog();
this.router.navigate(['/'], { replaceUrl: true }); this.router.navigate(['/'], { replaceUrl: true });
} else if (alertType === 'Tenant Changed') { } else if (alertType === 'Tenant Changed') {
this.updateLogo(); this.updateLogo();
@@ -323,9 +329,34 @@ export class LoginComponent implements OnInit, OnDestroy {
if (this.theme$) { if (this.theme$) {
this.theme$.unsubscribe(); this.theme$.unsubscribe();
} }
this.closeTwoFactorDialog();
clearInterval(this.slideTimer); clearInterval(this.slideTimer);
} }
/** Open the 2FA challenge as a non-dismissable modal */
private openTwoFactorDialog(): void {
if (this.twoFactorDialogRef) return;
this.twoFactorDialogRef = this.dialog.open(TwoFactorAuthenticationComponent, {
width: '440px',
maxWidth: '95vw',
panelClass: 'tfa-dialog-panel',
backdropClass: 'tfa-dialog-backdrop',
disableClose: true,
autoFocus: 'first-tabbable',
restoreFocus: true
});
this.twoFactorDialogRef.afterClosed().subscribe(() => {
this.twoFactorDialogRef = null;
});
}
private closeTwoFactorDialog(): void {
if (this.twoFactorDialogRef) {
this.twoFactorDialogRef.close();
this.twoFactorDialogRef = null;
}
}
goToSlide(index: number): void { goToSlide(index: number): void {
this.currentSlide = index; this.currentSlide = index;
this.restartSlider(); this.restartSlider();
@@ -6,74 +6,149 @@
file, You can obtain one at http://mozilla.org/MPL/2.0/. file, You can obtain one at http://mozilla.org/MPL/2.0/.
--> -->
<p> <div class="tfa-card">
<strong>{{ 'labels.text.Two Factor Authentication' | translate }}</strong> <div class="tfa-icon">
</p> <fa-icon icon="shield-alt"></fa-icon>
</div>
<mat-divider></mat-divider> <h2 class="tfa-title">{{ 'labels.text.Two Factor Authentication' | translate }}</h2>
<p class="tfa-sub">
@if (!otpRequested) {
Choose how you want to receive your one-time passcode.
} @else {
Enter the 6-digit code we sent you to continue.
}
</p>
<!-- Select delivery method to receive OTP --> <!-- Step 1 — Pick a delivery method -->
@if (!otpRequested) { @if (!otpRequested) {
<p>{{ 'labels.text.Please select a delivery method' | translate }}:</p> <form class="tfa-form" [formGroup]="twoFactorAuthenticationDeliveryMethodForm" (ngSubmit)="requestOTP()">
<div class="tfa-methods" formGroupName="">
<mat-radio-group class="tfa-methods__group" formControlName="twoFactorAuthenticationDeliveryMethod">
@for (method of twoFactorAuthenticationDeliveryMethods; track method.name) {
<div
class="tfa-method"
role="radio"
tabindex="0"
[attr.aria-checked]="
twoFactorAuthenticationDeliveryMethodForm.value.twoFactorAuthenticationDeliveryMethod === method
"
[class.is-selected]="
twoFactorAuthenticationDeliveryMethodForm.value.twoFactorAuthenticationDeliveryMethod === method
"
(click)="selectDeliveryMethod(method)"
(keydown.enter)="selectDeliveryMethod(method)"
(keydown.space)="selectDeliveryMethod(method); $event.preventDefault()"
>
<mat-radio-button
[value]="method"
class="tfa-method__radio"
(click)="$event.stopPropagation()"
></mat-radio-button>
<div class="tfa-method__icon">
<fa-icon
[icon]="
(method.name | lowercase) === 'sms' || (method.name | lowercase) === 'mobile'
? 'mobile-alt'
: 'envelope'
"
></fa-icon>
</div>
<div class="tfa-method__body">
<span class="tfa-method__name">{{ method.name | titlecase }}</span>
<span class="tfa-method__target">{{ maskTarget(method) }}</span>
</div>
</div>
}
</mat-radio-group>
</div>
<form class="two-factor-auth-form" [formGroup]="twoFactorAuthenticationDeliveryMethodForm" (ngSubmit)="requestOTP()"> <button
<mat-radio-group class="radio-group-spacing" formControlName="twoFactorAuthenticationDeliveryMethod"> type="submit"
@for ( class="tfa-btn tfa-btn--primary"
twoFactorAuthenticationDeliveryMethod of twoFactorAuthenticationDeliveryMethods; [disabled]="!twoFactorAuthenticationDeliveryMethodForm.valid || loading"
track twoFactorAuthenticationDeliveryMethod >
) { @if (loading) {
<mat-radio-button [value]="twoFactorAuthenticationDeliveryMethod"> <mat-spinner diameter="20"></mat-spinner>
{{ 'labels.inputs.Send' | translate }} {{ twoFactorAuthenticationDeliveryMethod.name }} <span>Sending…</span>
{{ 'labels.inputs.to' | translate }} {{ twoFactorAuthenticationDeliveryMethod.target }} } @else {
</mat-radio-button> <fa-icon icon="paper-plane"></fa-icon>
} <span>{{ 'labels.buttons.Request OTP' | translate }}</span>
</mat-radio-group> }
<button mat-raised-button color="primary" [disabled]="!twoFactorAuthenticationDeliveryMethodForm.valid || loading"> </button>
{{ 'labels.buttons.Request OTP' | translate }} </form>
@if (loading) { }
<mat-spinner [diameter]="20"></mat-spinner>
}
</button>
</form>
}
<!-- Show input for OTP --> <!-- Step 2 — Enter the code -->
@if (otpRequested) { @if (otpRequested) {
<p>{{ 'labels.text.Please enter the OTP' | translate }}:</p> <div class="tfa-context">
<fa-icon
<form class="two-factor-auth-form" [formGroup]="twoFactorAuthenticationForm" (ngSubmit)="validateOTP()"> [icon]="
<mat-form-field class="two-factor-auth-input" appearance="fill"> (selectedTwoFactorAuthenticationDeliveryMethod.name | lowercase) === 'sms' ||
<span matPrefix> (selectedTwoFactorAuthenticationDeliveryMethod.name | lowercase) === 'mobile'
<fa-icon icon="user-shield" class="m-r-10"></fa-icon> ? 'mobile-alt'
: 'envelope'
"
></fa-icon>
<div>
<span class="tfa-context__label">Code sent to</span>
<strong class="tfa-context__target">{{ maskTarget(selectedTwoFactorAuthenticationDeliveryMethod) }}</strong>
</div>
<span class="tfa-context__validity">
<fa-icon icon="clock"></fa-icon>
{{ tokenValidityTime / 60 }} {{ 'labels.inputs.mins' | translate }}
</span> </span>
<mat-label>{{ 'labels.inputs.OTP' | translate }}</mat-label> </div>
<input type="password" matInput autocomplete="off" required formControlName="otp" />
<mat-hint align="start"> <form class="tfa-form" [formGroup]="twoFactorAuthenticationForm" (ngSubmit)="validateOTP()">
<strong>{{ 'labels.inputs.Delivery Method' | translate }}:</strong> <div class="tfa-otp">
{{ selectedTwoFactorAuthenticationDeliveryMethod.name }} <input
</mat-hint> type="text"
<mat-hint align="end"> inputmode="numeric"
<strong>{{ 'labels.inputs.Validity' | translate }}:</strong> {{ tokenValidityTime / 60 }} autocomplete="one-time-code"
{{ 'labels.inputs.mins' | translate }} maxlength="6"
</mat-hint> class="tfa-otp__input"
@if (twoFactorAuthenticationForm.controls.otp.hasError('required')) { formControlName="otp"
<mat-error> placeholder="• • • • • •"
(keypress)="$event.charCode >= 48 && $event.charCode <= 57 ? true : $event.preventDefault()"
/>
</div>
@if (
twoFactorAuthenticationForm.controls.otp.hasError('required') &&
twoFactorAuthenticationForm.controls.otp.touched
) {
<div class="tfa-error">
<fa-icon icon="exclamation-circle"></fa-icon>
{{ 'labels.inputs.OTP' | translate }} {{ 'labels.commons.is' | translate }} {{ 'labels.inputs.OTP' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong> <strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error> </div>
} }
</mat-form-field>
<button mat-raised-button color="primary" [disabled]="!twoFactorAuthenticationForm.valid || loading"> <button type="submit" class="tfa-btn tfa-btn--primary" [disabled]="!twoFactorAuthenticationForm.valid || loading">
{{ 'labels.buttons.Validate OTP' | translate }} @if (loading) {
@if (loading) { <mat-spinner diameter="20"></mat-spinner>
<mat-spinner [diameter]="20"></mat-spinner> <span>Verifying…</span>
} } @else {
</button> <fa-icon icon="check-circle"></fa-icon>
<button type="button" mat-button (click)="resendOTP()" [disabled]="loading || resendOTPLoading"> <span>{{ 'labels.buttons.Validate OTP' | translate }}</span>
{{ 'labels.buttons.Resend OTP' | translate }} }
@if (resendOTPLoading) { </button>
<mat-spinner [diameter]="20"></mat-spinner>
} <button
</button> type="button"
</form> class="tfa-btn tfa-btn--ghost"
} (click)="resendOTP()"
[disabled]="loading || resendOTPLoading"
>
@if (resendOTPLoading) {
<mat-spinner diameter="18"></mat-spinner>
<span>Resending…</span>
} @else {
<fa-icon icon="redo"></fa-icon>
<span>{{ 'labels.buttons.Resend OTP' | translate }}</span>
}
</button>
</form>
}
</div>
@@ -6,180 +6,338 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/. * file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/ */
// ===== Host Container =====
:host { :host {
display: flex; display: flex;
flex-direction: column;
width: 100%; width: 100%;
animation: fade-in-up 0.4s ease-out; justify-content: center;
animation: tfa-fade 0.4s ease-out;
} }
// ===== Title Section ===== .tfa-card {
p { width: 100%;
text-align: center; max-width: 400px;
font-size: 1.1rem; padding: 24px 22px 22px;
color: rgb(255 255 255 / 90%); border-radius: 18px;
margin: 0 0 1rem; background: rgb(255 255 255 / 6%);
border: 1px solid rgb(255 255 255 / 12%);
strong { backdrop-filter: blur(8px);
font-weight: 600;
color: inherit;
}
}
// ===== Divider =====
mat-divider {
margin-bottom: 1.5rem;
}
// ===== Delivery Method Description =====
p:not(:first-of-type) {
text-align: center;
font-size: 0.95rem;
color: rgb(255 255 255 / 65%);
margin-bottom: 1rem;
}
// ===== Main Form Container =====
.two-factor-auth-form {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: stretch;
}
.tfa-icon {
align-self: center;
width: 56px;
height: 56px;
border-radius: 16px;
background: linear-gradient(135deg, #2563eb 0%, #1e40af 100%);
color: #fff;
display: inline-flex;
align-items: center; align-items: center;
width: 100%; justify-content: center;
gap: 1rem; font-size: 24px;
box-shadow:
0 6px 20px rgb(37 99 235 / 35%),
inset 0 1px 0 rgb(255 255 255 / 25%);
margin-bottom: 14px;
}
mat-radio-button { .tfa-title {
margin-bottom: 0.25rem; margin: 0 0 6px;
text-align: center;
font-size: 18px;
font-weight: 700;
color: #fff;
letter-spacing: 0.2px;
}
&:last-child { .tfa-sub {
margin-bottom: 0; margin: 0 0 20px;
} text-align: center;
font-size: 13px;
color: rgb(255 255 255 / 65%);
line-height: 1.5;
}
// Style radio buttons to match Material Design 3 /* ──────── Form layout ──────── */
::ng-deep { .tfa-form {
.mdc-form-field { display: flex;
font-size: 0.95rem; flex-direction: column;
color: var(--md-sys-color-on-surface, #1a1c1e); gap: 12px;
} }
.mdc-radio__outer-circle { /* ──────── Step 1 — delivery methods ──────── */
border-color: var(--md-sys-color-on-surface-variant, #44474e); .tfa-methods__group {
} display: flex;
flex-direction: column;
gap: 10px;
}
.mdc-radio--selected .mdc-radio__outer-circle, .tfa-method {
.mdc-radio--selected .mdc-radio__inner-circle { display: flex;
border-color: var(--md-sys-color-primary, #1074b9); align-items: center;
} gap: 12px;
} padding: 12px 14px;
border: 1px solid rgb(255 255 255 / 12%);
background: rgb(255 255 255 / 4%);
border-radius: 12px;
cursor: pointer;
transition:
border-color 0.18s,
background 0.18s,
box-shadow 0.18s;
&:hover {
border-color: rgb(96 165 250 / 60%);
background: rgb(96 165 250 / 8%);
} }
// ===== OTP Input Field ===== &.is-selected {
.two-factor-auth-input { border-color: #60a5fa;
width: 100%; background: rgb(96 165 250 / 12%);
max-width: 320px; box-shadow:
inset 0 0 0 1px #60a5fa,
[matPrefix] { 0 6px 18px rgb(37 99 235 / 28%);
padding-left: 1rem;
fa-icon {
margin-right: 0.75rem;
}
}
::ng-deep {
.mat-mdc-text-field-wrapper {
border-radius: 12px 12px 0 0;
}
.mat-mdc-form-field-focus-overlay {
border-radius: 12px 12px 0 0;
background-color: var(--md-sys-color-on-surface, #1a1c1e);
opacity: 0.04;
}
.mdc-line-ripple::after {
border-bottom-color: var(--md-sys-color-primary, #1074b9);
}
.mat-mdc-form-field-hint-wrapper {
padding: 0.5rem 1rem;
}
mat-hint {
font-size: 0.75rem;
color: var(--md-sys-color-on-surface-variant, #44474e);
}
}
} }
// ===== Buttons ===== &__icon {
.two-factor-auth-button, width: 36px;
[mat-raised-button], height: 36px;
[mat-button] { border-radius: 999px;
width: 100%; background: rgb(255 255 255 / 8%);
max-width: 320px; color: #93c5fd;
height: 48px; display: inline-flex;
border-radius: 24px;
font-size: 0.95rem;
font-weight: 500;
letter-spacing: 0.02em;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 0.5rem; font-size: 14px;
flex-shrink: 0;
} }
[mat-raised-button] { &.is-selected &__icon {
background: var(--md-sys-color-primary, #1074b9); background: #1d4ed8;
color: var(--md-sys-color-on-primary, #fff); color: #fff;
box-shadow:
0 1px 2px rgb(0 0 0 / 30%),
0 1px 3px 1px rgb(0 0 0 / 15%);
&:hover:not([disabled]) {
box-shadow:
0 1px 3px rgb(0 0 0 / 30%),
0 4px 8px 3px rgb(0 0 0 / 15%);
}
&[disabled] {
background: var(--md-sys-color-on-surface, #1a1c1e);
opacity: 0.38;
}
} }
[mat-button] { &__body {
color: var(--md-sys-color-primary, #1074b9); display: flex;
background: transparent; flex-direction: column;
min-width: 0;
&:hover:not([disabled]) { flex: 1;
background: rgb(16 116 185 / 8%);
}
} }
mat-spinner { &__name {
display: inline-block; font-size: 13px;
margin-left: 0.5rem; font-weight: 700;
color: rgb(255 255 255 / 92%);
text-transform: uppercase;
letter-spacing: 0.4px;
}
&__target {
font-size: 12.5px;
color: rgb(255 255 255 / 70%);
margin-top: 2px;
}
&__radio {
margin-left: auto;
::ng-deep .mdc-radio__outer-circle {
border-color: rgb(255 255 255 / 35%);
}
::ng-deep .mat-mdc-radio-button.mat-accent .mdc-radio__inner-circle,
::ng-deep .mdc-radio--selected .mdc-radio__outer-circle {
border-color: #60a5fa !important;
}
} }
} }
// ===== Radio Group Spacing ===== /* ──────── Step 2 — context strip ──────── */
.radio-group-spacing { .tfa-context {
display: flex; display: flex;
flex-direction: column;
align-items: center; align-items: center;
gap: 0.75rem; gap: 10px;
width: 100%; padding: 10px 14px;
padding: 0.5rem 0; margin-bottom: 16px;
margin-bottom: 0.5rem; border-radius: 12px;
background: rgb(255 255 255 / 5%);
border: 1px solid rgb(255 255 255 / 10%);
> fa-icon {
color: #93c5fd;
font-size: 16px;
flex-shrink: 0;
}
> div {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
}
&__label {
font-size: 11px;
color: rgb(255 255 255 / 55%);
letter-spacing: 0.4px;
text-transform: uppercase;
}
&__target {
font-size: 13px;
font-weight: 700;
color: #fff;
word-break: break-all;
}
&__validity {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 4px 10px;
border-radius: 999px;
background: rgb(34 197 94 / 18%);
color: #86efac;
border: 1px solid rgb(34 197 94 / 35%);
font-size: 11px;
font-weight: 700;
flex-shrink: 0;
fa-icon {
font-size: 10px;
}
}
} }
// ===== Animations ===== /* ──────── Step 2 — OTP input ──────── */
@keyframes fade-in-up { .tfa-otp {
display: flex;
justify-content: center;
}
.tfa-otp__input {
width: 100%;
max-width: 320px;
height: 60px;
text-align: center;
border: 1px solid rgb(255 255 255 / 18%);
background: rgb(255 255 255 / 8%);
border-radius: 14px;
color: #fff;
font-family: 'SF Mono', 'Cascadia Code', Consolas, monospace;
font-size: 28px;
font-weight: 700;
letter-spacing: 14px;
padding: 0 0 0 14px;
outline: none;
transition:
border-color 0.18s,
box-shadow 0.18s,
background 0.18s;
&::placeholder {
color: rgb(255 255 255 / 25%);
letter-spacing: 14px;
}
&:focus {
border-color: #60a5fa;
background: rgb(255 255 255 / 12%);
box-shadow: 0 0 0 4px rgb(96 165 250 / 25%);
}
}
.tfa-error {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 8px 12px;
border-radius: 10px;
background: rgb(239 68 68 / 14%);
border: 1px solid rgb(239 68 68 / 35%);
color: #fecaca;
font-size: 12.5px;
fa-icon {
font-size: 13px;
color: #fca5a5;
}
strong {
color: #fff;
font-weight: 700;
}
}
/* ──────── Buttons ──────── */
.tfa-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
height: 48px;
border-radius: 12px;
font-size: 14px;
font-weight: 700;
letter-spacing: 0.3px;
cursor: pointer;
transition:
background 0.18s,
box-shadow 0.18s,
transform 0.18s,
opacity 0.18s;
border: 1px solid transparent;
fa-icon {
font-size: 14px;
}
&:disabled {
cursor: not-allowed;
opacity: 0.6;
}
&--primary {
background: linear-gradient(135deg, #2563eb 0%, #1e40af 100%);
color: #fff;
box-shadow:
0 6px 20px rgb(37 99 235 / 32%),
inset 0 1px 0 rgb(255 255 255 / 18%);
&:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow:
0 10px 24px rgb(37 99 235 / 42%),
inset 0 1px 0 rgb(255 255 255 / 22%);
}
}
&--ghost {
background: transparent;
color: rgb(255 255 255 / 80%);
border-color: rgb(255 255 255 / 18%);
&:hover:not(:disabled) {
background: rgb(255 255 255 / 8%);
color: #fff;
border-color: rgb(255 255 255 / 30%);
}
}
mat-spinner ::ng-deep circle {
stroke: #fff !important;
}
}
/* ──────── Animations ──────── */
@keyframes tfa-fade {
from { from {
opacity: 0; opacity: 0;
transform: translateY(10px); transform: translateY(8px);
} }
to { to {
@@ -188,61 +346,16 @@ p:not(:first-of-type) {
} }
} }
// ===== Dark Mode Support ===== /* ──────── Responsive ──────── */
:host-context(.dark-theme), @media (width <= 480px) {
:host-context([data-theme='dark']) { .tfa-card {
p { padding: 20px 16px;
color: var(--md-sys-color-on-surface, #e2e2e5); border-radius: 16px;
strong {
color: inherit;
}
} }
p:not(:first-of-type) { .tfa-otp__input {
color: var(--md-sys-color-on-surface-variant, #c4c6cf); font-size: 22px;
} letter-spacing: 10px;
padding-left: 10px;
.two-factor-auth-form {
mat-radio-button {
::ng-deep {
.mdc-form-field {
color: var(--md-sys-color-on-surface, #e2e2e5);
}
.mdc-radio__outer-circle {
border-color: var(--md-sys-color-on-surface-variant, #c4c6cf);
}
}
}
[mat-raised-button] {
background: var(--md-sys-color-primary, #a8c8ff);
color: var(--md-sys-color-on-primary, #003258);
}
[mat-button] {
color: var(--md-sys-color-primary, #a8c8ff);
&:hover:not([disabled]) {
background: rgb(168 200 255 / 12%);
}
}
}
}
// ===== Responsive Styles =====
/* stylelint-disable-next-line media-feature-range-notation -- Safari compatibility */
@media (max-width: 768px) {
.radio-group-spacing {
gap: 0.5rem;
}
.two-factor-auth-form {
.two-factor-auth-input,
[mat-raised-button],
[mat-button] {
max-width: 100%;
}
} }
} }
@@ -15,10 +15,8 @@ import { finalize } from 'rxjs/operators';
/** Custom Services */ /** Custom Services */
import { AuthenticationService } from '../../core/authentication/authentication.service'; import { AuthenticationService } from '../../core/authentication/authentication.service';
import { MatDivider } from '@angular/material/divider';
import { MatRadioGroup, MatRadioButton } from '@angular/material/radio'; import { MatRadioGroup, MatRadioButton } from '@angular/material/radio';
import { MatProgressSpinner } from '@angular/material/progress-spinner'; import { MatProgressSpinner } from '@angular/material/progress-spinner';
import { MatFormField, MatPrefix, MatLabel, MatHint, MatError } from '@angular/material/form-field';
import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
@@ -31,13 +29,10 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
styleUrls: ['./two-factor-authentication.component.scss'], styleUrls: ['./two-factor-authentication.component.scss'],
imports: [ imports: [
...STANDALONE_SHARED_IMPORTS, ...STANDALONE_SHARED_IMPORTS,
MatDivider,
MatRadioGroup, MatRadioGroup,
MatRadioButton, MatRadioButton,
MatProgressSpinner, MatProgressSpinner,
MatPrefix, FaIconComponent
FaIconComponent,
MatHint
] ]
}) })
export class TwoFactorAuthenticationComponent implements OnInit { export class TwoFactorAuthenticationComponent implements OnInit {
@@ -69,7 +64,18 @@ export class TwoFactorAuthenticationComponent implements OnInit {
ngOnInit() { ngOnInit() {
this.createTwoFactorAuthenticationDeliveryMethodForm(); this.createTwoFactorAuthenticationDeliveryMethodForm();
this.authenticationService.getDeliveryMethods().subscribe((deliveryMethods: any) => { this.authenticationService.getDeliveryMethods().subscribe((deliveryMethods: any) => {
this.twoFactorAuthenticationDeliveryMethods = deliveryMethods; // Keep only methods the user has actually configured (target populated).
// Methods returned without a destination are noise from the backend.
const enabled = (deliveryMethods || []).filter((m: any) => !!m?.target);
this.twoFactorAuthenticationDeliveryMethods = enabled;
// If exactly one method is enabled, skip the picker and request OTP automatically.
if (enabled.length === 1) {
this.twoFactorAuthenticationDeliveryMethodForm
.get('twoFactorAuthenticationDeliveryMethod')
?.setValue(enabled[0]);
this.requestOTP();
}
}); });
} }
@@ -120,6 +126,35 @@ export class TwoFactorAuthenticationComponent implements OnInit {
.subscribe(); .subscribe();
} }
/** Explicitly select a delivery method when the row is clicked. */
selectDeliveryMethod(method: any): void {
this.twoFactorAuthenticationDeliveryMethodForm.get('twoFactorAuthenticationDeliveryMethod')?.setValue(method);
}
/**
* Mask the delivery target so only the last 4 characters are visible.
* Mobile → "+XXXXXX2076"; Email → "ad***@gmail.com".
*/
maskTarget(method: any): string {
const target = method?.target || '';
if (!target) return '';
const channel = (method?.name || '').toString().toLowerCase();
const isMobile = channel === 'sms' || channel === 'mobile' || channel === 'phone';
if (isMobile) {
const digits = target.replace(/\D/g, '');
if (digits.length <= 4) return target;
return '+' + 'X'.repeat(digits.length - 4) + digits.slice(-4);
}
// Email — keep first 2 chars of local part, mask the rest, keep domain
const at = target.indexOf('@');
if (at <= 0) return target;
const local = target.substring(0, at);
const domain = target.substring(at);
const visible = local.substring(0, Math.min(2, local.length));
const hidden = 'X'.repeat(Math.max(0, local.length - visible.length));
return visible + hidden + domain;
}
/** /**
* Resends OTP via the selected delivery method. * Resends OTP via the selected delivery method.
*/ */
@@ -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';
}
}
+5
View File
@@ -456,6 +456,11 @@ $mono: 'Roboto Mono', 'DM Mono', monospace;
color: #475569; color: #475569;
} }
.cell-empty {
color: #94a3b8;
font-style: italic;
}
// ===== Status Badge ===== // ===== Status Badge =====
.status-badge { .status-badge {
display: inline-flex; display: inline-flex;
@@ -27,7 +27,8 @@
<div class="cu-grid" #userFormRef> <div class="cu-grid" #userFormRef>
<mat-form-field appearance="outline" class="cu-field"> <mat-form-field appearance="outline" class="cu-field">
<mat-label>{{ 'labels.inputs.Username' | translate }}</mat-label> <mat-label>{{ 'labels.inputs.Username' | translate }}</mat-label>
<input matInput required formControlName="username" /> <span matPrefix class="cu-prefix-icon"><mat-icon>person</mat-icon></span>
<input matInput required formControlName="username" placeholder="Login username" />
@if (userForm.controls.username.hasError('required')) { @if (userForm.controls.username.hasError('required')) {
<mat-error <mat-error
>{{ 'labels.inputs.Username' | translate }} {{ 'labels.commons.is' | translate }} >{{ 'labels.inputs.Username' | translate }} {{ 'labels.commons.is' | translate }}
@@ -36,26 +37,10 @@
} }
</mat-form-field> </mat-form-field>
<mat-form-field appearance="outline" class="cu-field">
<mat-label>{{ 'labels.inputs.Email' | translate }}</mat-label>
<input matInput [required]="userForm.controls.sendPasswordToEmail.value" formControlName="email" />
@if (userForm.controls.email.hasError('email')) {
<mat-error
>{{ 'labels.inputs.Email' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.invalid' | translate }}</strong></mat-error
>
}
@if (userForm.controls.email.hasError('required')) {
<mat-error
>{{ 'labels.inputs.Email' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong></mat-error
>
}
</mat-form-field>
<mat-form-field appearance="outline" class="cu-field"> <mat-form-field appearance="outline" class="cu-field">
<mat-label>{{ 'labels.inputs.First Name' | translate }}</mat-label> <mat-label>{{ 'labels.inputs.First Name' | translate }}</mat-label>
<input matInput required formControlName="firstname" /> <span matPrefix class="cu-prefix-icon"><mat-icon>badge</mat-icon></span>
<input matInput required formControlName="firstname" placeholder="Given name" />
@if (userForm.controls.firstname.hasError('pattern')) { @if (userForm.controls.firstname.hasError('pattern')) {
<mat-error <mat-error
>{{ 'labels.inputs.First Name' | translate }} >{{ 'labels.inputs.First Name' | translate }}
@@ -74,7 +59,8 @@
<mat-form-field appearance="outline" class="cu-field"> <mat-form-field appearance="outline" class="cu-field">
<mat-label>{{ 'labels.inputs.Last Name' | translate }}</mat-label> <mat-label>{{ 'labels.inputs.Last Name' | translate }}</mat-label>
<input matInput required formControlName="lastname" /> <span matPrefix class="cu-prefix-icon"><mat-icon>person_outline</mat-icon></span>
<input matInput required formControlName="lastname" placeholder="Family name" />
@if (userForm.controls.lastname.hasError('pattern')) { @if (userForm.controls.lastname.hasError('pattern')) {
<mat-error <mat-error
>{{ 'labels.inputs.Last Name' | translate }} >{{ 'labels.inputs.Last Name' | translate }}
@@ -90,6 +76,54 @@
> >
} }
</mat-form-field> </mat-form-field>
<mat-form-field appearance="outline" class="cu-field">
<mat-label>{{ 'labels.inputs.Email' | translate }}</mat-label>
<span matPrefix class="cu-prefix-icon"><mat-icon>email</mat-icon></span>
<input
matInput
[required]="userForm.controls.sendPasswordToEmail.value"
formControlName="email"
placeholder="name@example.com"
/>
@if (userForm.controls.email.hasError('email')) {
<mat-error
>{{ 'labels.inputs.Email' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.invalid' | translate }}</strong></mat-error
>
}
@if (userForm.controls.email.hasError('required')) {
<mat-error
>{{ 'labels.inputs.Email' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong></mat-error
>
}
</mat-form-field>
<mat-form-field appearance="outline" class="cu-field">
<mat-label>Mobile Number</mat-label>
<span matPrefix class="cu-mobile-prefix">
<mat-icon>phone_android</mat-icon>
<span class="cu-mobile-prefix__code">+91</span>
</span>
<input
matInput
required
formControlName="mobileNo"
maxlength="10"
placeholder="10-digit mobile number"
(keypress)="$event.charCode >= 48 && $event.charCode <= 57 ? true : $event.preventDefault()"
/>
@if (userForm.controls.mobileNo.hasError('required')) {
<mat-error
>Mobile number {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
@if (userForm.controls.mobileNo.hasError('pattern')) {
<mat-error>Enter a valid <strong>10-digit mobile number</strong></mat-error>
}
</mat-form-field>
</div> </div>
</div> </div>
@@ -179,6 +213,7 @@
<div class="cu-grid"> <div class="cu-grid">
<mat-form-field appearance="outline" class="cu-field"> <mat-form-field appearance="outline" class="cu-field">
<mat-label>{{ 'labels.inputs.Office' | translate }}</mat-label> <mat-label>{{ 'labels.inputs.Office' | translate }}</mat-label>
<span matPrefix class="cu-prefix-icon"><mat-icon>business</mat-icon></span>
<mat-select required formControlName="officeId"> <mat-select required formControlName="officeId">
@for (office of officesData; track office) { @for (office of officesData; track office) {
<mat-option [value]="office.id">{{ office.name }}</mat-option> <mat-option [value]="office.id">{{ office.name }}</mat-option>
@@ -194,6 +229,7 @@
<mat-form-field appearance="outline" class="cu-field"> <mat-form-field appearance="outline" class="cu-field">
<mat-label>{{ 'labels.inputs.Staff' | translate }}</mat-label> <mat-label>{{ 'labels.inputs.Staff' | translate }}</mat-label>
<span matPrefix class="cu-prefix-icon"><mat-icon>work</mat-icon></span>
<mat-select formControlName="staffId"> <mat-select formControlName="staffId">
@for (staff of staffData; track staff) { @for (staff of staffData; track staff) {
<mat-option [value]="staff.id">{{ staff.displayName }}</mat-option> <mat-option [value]="staff.id">{{ staff.displayName }}</mat-option>
@@ -203,6 +239,7 @@
<mat-form-field appearance="outline" class="cu-field cu-field--full"> <mat-form-field appearance="outline" class="cu-field cu-field--full">
<mat-label>{{ 'labels.inputs.Roles' | translate }}</mat-label> <mat-label>{{ 'labels.inputs.Roles' | translate }}</mat-label>
<span matPrefix class="cu-prefix-icon"><mat-icon>verified_user</mat-icon></span>
<mat-select required formControlName="roles" multiple> <mat-select required formControlName="roles" multiple>
@for (role of rolesData; track role) { @for (role of rolesData; track role) {
<mat-option [value]="role.id">{{ role.name }}</mat-option> <mat-option [value]="role.id">{{ role.name }}</mat-option>
@@ -110,6 +110,50 @@ $gray-900: #202124;
} }
} }
// Prefix icon (matches client-creation field-prefix-icon)
.cu-prefix-icon {
display: inline-flex;
align-items: center;
padding-left: 4px;
padding-right: 8px;
margin-right: 8px;
border-right: 1.5px solid #cbd5e1;
line-height: 1;
mat-icon {
font-size: 20px;
width: 20px;
height: 20px;
color: $primary;
}
}
// Combined icon + country-code prefix (mobile field)
.cu-mobile-prefix {
display: inline-flex;
align-items: center;
gap: 8px;
padding-left: 4px;
padding-right: 10px;
margin-right: 10px;
border-right: 1.5px solid #cbd5e1;
line-height: 1;
mat-icon {
font-size: 20px;
width: 20px;
height: 20px;
color: $primary;
}
&__code {
font-weight: 700;
font-size: 13px;
color: $gray-900;
letter-spacing: 0.3px;
}
}
.cu-checkbox-row { .cu-checkbox-row {
grid-column: 1 / -1; grid-column: 1 / -1;
display: flex; display: flex;
@@ -28,6 +28,7 @@ import { confirmPasswordValidator } from 'app/login/reset-password/confirm-passw
import { ConfigurationWizardService } from 'app/configuration-wizard/configuration-wizard.service'; import { ConfigurationWizardService } from 'app/configuration-wizard/configuration-wizard.service';
import { ContinueSetupDialogComponent } from 'app/configuration-wizard/continue-setup-dialog/continue-setup-dialog.component'; import { ContinueSetupDialogComponent } from 'app/configuration-wizard/continue-setup-dialog/continue-setup-dialog.component';
import { MatCheckbox } from '@angular/material/checkbox'; import { MatCheckbox } from '@angular/material/checkbox';
import { MatIcon } from '@angular/material/icon';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/** /**
@@ -39,7 +40,8 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
styleUrls: ['./create-user.component.scss'], styleUrls: ['./create-user.component.scss'],
imports: [ imports: [
...STANDALONE_SHARED_IMPORTS, ...STANDALONE_SHARED_IMPORTS,
MatCheckbox MatCheckbox,
MatIcon
] ]
}) })
export class CreateUserComponent implements OnInit, AfterViewInit { export class CreateUserComponent implements OnInit, AfterViewInit {
@@ -108,6 +110,13 @@ export class CreateUserComponent implements OnInit, AfterViewInit {
Validators.email Validators.email
] ]
], ],
mobileNo: [
'',
[
Validators.required,
Validators.pattern(/^\d{10}$/)
]
],
firstname: [ firstname: [
'', '',
[ [
@@ -27,7 +27,8 @@
<div class="eu-grid"> <div class="eu-grid">
<mat-form-field appearance="outline" class="eu-field"> <mat-form-field appearance="outline" class="eu-field">
<mat-label>{{ 'labels.inputs.Username' | translate }}</mat-label> <mat-label>{{ 'labels.inputs.Username' | translate }}</mat-label>
<input matInput required formControlName="username" /> <span matPrefix class="eu-prefix-icon"><mat-icon>person</mat-icon></span>
<input matInput required formControlName="username" placeholder="Login username" />
@if (editUserForm.controls.username.hasError('required')) { @if (editUserForm.controls.username.hasError('required')) {
<mat-error <mat-error
>{{ 'labels.inputs.Username' | translate }} {{ 'labels.commons.is' | translate }} >{{ 'labels.inputs.Username' | translate }} {{ 'labels.commons.is' | translate }}
@@ -36,26 +37,10 @@
} }
</mat-form-field> </mat-form-field>
<mat-form-field appearance="outline" class="eu-field">
<mat-label>{{ 'labels.inputs.Email' | translate }}</mat-label>
<input matInput required formControlName="email" />
@if (editUserForm.controls.email.hasError('email')) {
<mat-error
>{{ 'labels.inputs.Email' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.invalid' | translate }}</strong></mat-error
>
}
@if (editUserForm.controls.email.hasError('required')) {
<mat-error
>{{ 'labels.inputs.Email' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong></mat-error
>
}
</mat-form-field>
<mat-form-field appearance="outline" class="eu-field"> <mat-form-field appearance="outline" class="eu-field">
<mat-label>{{ 'labels.inputs.First Name' | translate }}</mat-label> <mat-label>{{ 'labels.inputs.First Name' | translate }}</mat-label>
<input matInput required formControlName="firstname" /> <span matPrefix class="eu-prefix-icon"><mat-icon>badge</mat-icon></span>
<input matInput required formControlName="firstname" placeholder="Given name" />
@if (editUserForm.controls.firstname.hasError('pattern')) { @if (editUserForm.controls.firstname.hasError('pattern')) {
<mat-error <mat-error
>{{ 'labels.inputs.First Name' | translate }} >{{ 'labels.inputs.First Name' | translate }}
@@ -74,7 +59,8 @@
<mat-form-field appearance="outline" class="eu-field"> <mat-form-field appearance="outline" class="eu-field">
<mat-label>{{ 'labels.inputs.Last Name' | translate }}</mat-label> <mat-label>{{ 'labels.inputs.Last Name' | translate }}</mat-label>
<input matInput required formControlName="lastname" /> <span matPrefix class="eu-prefix-icon"><mat-icon>person_outline</mat-icon></span>
<input matInput required formControlName="lastname" placeholder="Family name" />
@if (editUserForm.controls.lastname.hasError('pattern')) { @if (editUserForm.controls.lastname.hasError('pattern')) {
<mat-error <mat-error
>{{ 'labels.inputs.Last Name' | translate }} >{{ 'labels.inputs.Last Name' | translate }}
@@ -90,6 +76,49 @@
> >
} }
</mat-form-field> </mat-form-field>
<mat-form-field appearance="outline" class="eu-field">
<mat-label>{{ 'labels.inputs.Email' | translate }}</mat-label>
<span matPrefix class="eu-prefix-icon"><mat-icon>email</mat-icon></span>
<input matInput required formControlName="email" placeholder="name@example.com" />
@if (editUserForm.controls.email.hasError('email')) {
<mat-error
>{{ 'labels.inputs.Email' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.invalid' | translate }}</strong></mat-error
>
}
@if (editUserForm.controls.email.hasError('required')) {
<mat-error
>{{ 'labels.inputs.Email' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong></mat-error
>
}
</mat-form-field>
<mat-form-field appearance="outline" class="eu-field">
<mat-label>Mobile Number</mat-label>
<span matPrefix class="eu-mobile-prefix">
<mat-icon>phone_android</mat-icon>
<span class="eu-mobile-prefix__code">+91</span>
</span>
<input
matInput
required
formControlName="mobileNo"
maxlength="10"
placeholder="10-digit mobile number"
(keypress)="$event.charCode >= 48 && $event.charCode <= 57 ? true : $event.preventDefault()"
/>
@if (editUserForm.controls.mobileNo.hasError('required')) {
<mat-error
>Mobile number {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
@if (editUserForm.controls.mobileNo.hasError('pattern')) {
<mat-error>Enter a valid <strong>10-digit mobile number</strong></mat-error>
}
</mat-form-field>
</div> </div>
</div> </div>
@@ -117,6 +146,7 @@
<div class="eu-grid"> <div class="eu-grid">
<mat-form-field appearance="outline" class="eu-field"> <mat-form-field appearance="outline" class="eu-field">
<mat-label>{{ 'labels.inputs.Office' | translate }}</mat-label> <mat-label>{{ 'labels.inputs.Office' | translate }}</mat-label>
<span matPrefix class="eu-prefix-icon"><mat-icon>business</mat-icon></span>
<mat-select required formControlName="officeId" (selectionChange)="officeChanged($event.value)"> <mat-select required formControlName="officeId" (selectionChange)="officeChanged($event.value)">
@for (office of officesData; track office) { @for (office of officesData; track office) {
<mat-option [value]="office.id">{{ office.name }}</mat-option> <mat-option [value]="office.id">{{ office.name }}</mat-option>
@@ -132,6 +162,7 @@
<mat-form-field appearance="outline" class="eu-field"> <mat-form-field appearance="outline" class="eu-field">
<mat-label>{{ 'labels.inputs.Staff' | translate }}</mat-label> <mat-label>{{ 'labels.inputs.Staff' | translate }}</mat-label>
<span matPrefix class="eu-prefix-icon"><mat-icon>work</mat-icon></span>
<mat-select formControlName="staffId"> <mat-select formControlName="staffId">
@for (staff of staffData; track staff) { @for (staff of staffData; track staff) {
<mat-option [value]="staff.id">{{ staff.displayName }}</mat-option> <mat-option [value]="staff.id">{{ staff.displayName }}</mat-option>
@@ -141,6 +172,7 @@
<mat-form-field appearance="outline" class="eu-field eu-field--full"> <mat-form-field appearance="outline" class="eu-field eu-field--full">
<mat-label>{{ 'labels.inputs.Roles' | translate }}</mat-label> <mat-label>{{ 'labels.inputs.Roles' | translate }}</mat-label>
<span matPrefix class="eu-prefix-icon"><mat-icon>verified_user</mat-icon></span>
<mat-select required formControlName="roles" multiple> <mat-select required formControlName="roles" multiple>
@for (role of rolesData; track role) { @for (role of rolesData; track role) {
<mat-option [value]="role.id">{{ role.name }}</mat-option> <mat-option [value]="role.id">{{ role.name }}</mat-option>
@@ -110,6 +110,50 @@ $gray-900: #202124;
} }
} }
// Prefix icon (matches client-creation field-prefix-icon)
.eu-prefix-icon {
display: inline-flex;
align-items: center;
padding-left: 4px;
padding-right: 8px;
margin-right: 8px;
border-right: 1.5px solid #cbd5e1;
line-height: 1;
mat-icon {
font-size: 20px;
width: 20px;
height: 20px;
color: $primary;
}
}
// Combined icon + country-code prefix (mobile field)
.eu-mobile-prefix {
display: inline-flex;
align-items: center;
gap: 8px;
padding-left: 4px;
padding-right: 10px;
margin-right: 10px;
border-right: 1.5px solid #cbd5e1;
line-height: 1;
mat-icon {
font-size: 20px;
width: 20px;
height: 20px;
color: $primary;
}
&__code {
font-weight: 700;
font-size: 13px;
color: $gray-900;
letter-spacing: 0.3px;
}
}
.eu-checkbox-row { .eu-checkbox-row {
grid-column: 1 / -1; grid-column: 1 / -1;
display: flex; display: flex;
+10 -1
View File
@@ -14,6 +14,7 @@ import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule }
/** Custom Services */ /** Custom Services */
import { UsersService } from '../users.service'; import { UsersService } from '../users.service';
import { MatCheckbox } from '@angular/material/checkbox'; import { MatCheckbox } from '@angular/material/checkbox';
import { MatIcon } from '@angular/material/icon';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/** /**
@@ -25,7 +26,8 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
styleUrls: ['./edit-user.component.scss'], styleUrls: ['./edit-user.component.scss'],
imports: [ imports: [
...STANDALONE_SHARED_IMPORTS, ...STANDALONE_SHARED_IMPORTS,
MatCheckbox MatCheckbox,
MatIcon
] ]
}) })
export class EditUserComponent implements OnInit { export class EditUserComponent implements OnInit {
@@ -82,6 +84,13 @@ export class EditUserComponent implements OnInit {
Validators.email Validators.email
] ]
], ],
mobileNo: [
this.userData.mobileNo || '',
[
Validators.required,
Validators.pattern(/^\d{10}$/)
]
],
firstname: [ firstname: [
this.userData.firstname, this.userData.firstname,
[ [
+11 -13
View File
@@ -60,24 +60,22 @@
<td mat-cell *matCellDef="let user" class="cell-mono">{{ user.email }}</td> <td mat-cell *matCellDef="let user" class="cell-mono">{{ user.email }}</td>
</ng-container> </ng-container>
<ng-container matColumnDef="mobileNo">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Mobile</th>
<td mat-cell *matCellDef="let user" class="cell-mono">
@if (user.mobileNo) {
+91 {{ user.mobileNo }}
} @else {
<span class="cell-empty">—</span>
}
</td>
</ng-container>
<ng-container matColumnDef="officeName"> <ng-container matColumnDef="officeName">
<th mat-header-cell *matHeaderCellDef mat-sort-header>{{ 'labels.inputs.Office' | translate }}</th> <th mat-header-cell *matHeaderCellDef mat-sort-header>{{ 'labels.inputs.Office' | translate }}</th>
<td mat-cell *matCellDef="let user">{{ user.officeName }}</td> <td mat-cell *matCellDef="let user">{{ user.officeName }}</td>
</ng-container> </ng-container>
<ng-container matColumnDef="isSelfServiceUser">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.buttons.Is Self Service' | translate }}</th>
<td mat-cell *matCellDef="let user">
<span
class="status-badge"
[class.status-active]="user.isSelfServiceUser"
[class.status-inactive]="!user.isSelfServiceUser"
>
{{ user.isSelfServiceUser ? ('labels.buttons.Yes' | translate) : ('labels.buttons.No' | translate) }}
</span>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr> <tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns" [routerLink]="[row.id]" class="table-row"></tr> <tr mat-row *matRowDef="let row; columns: displayedColumns" [routerLink]="[row.id]" class="table-row"></tr>
</table> </table>
+2 -2
View File
@@ -73,8 +73,8 @@ export class UsersComponent implements OnInit, AfterViewInit {
'firstname', 'firstname',
'lastname', 'lastname',
'email', 'email',
'officeName', 'mobileNo',
'isSelfServiceUser' 'officeName'
]; ];
/** Data source for users table. */ /** Data source for users table. */
dataSource: MatTableDataSource<any>; dataSource: MatTableDataSource<any>;
@@ -72,6 +72,13 @@
</div> </div>
} }
@if (userData.mobileNo) {
<div class="vu-cell">
<div class="vu-cell__label">Mobile</div>
<div class="vu-cell__value">+91 {{ userData.mobileNo }}</div>
</div>
}
<div class="vu-cell"> <div class="vu-cell">
<div class="vu-cell__label">{{ 'labels.inputs.Roles' | translate }}</div> <div class="vu-cell__label">{{ 'labels.inputs.Roles' | translate }}</div>
<div class="vu-cell__value"> <div class="vu-cell__value">
+2 -2
View File
@@ -9,8 +9,8 @@
/* tslint:disable */ /* tslint:disable */
export default { export default {
'mifos_x': { 'mifos_x': {
'version': '260429', 'version': '260509',
'hash': '0d3d8379' 'hash': 'g2d4fb4237'
}, },
'allow_switching_backend_instance': true 'allow_switching_backend_instance': true
}; };
+26
View File
@@ -1041,3 +1041,29 @@ mat-card {
margin: 0 !important; margin: 0 !important;
} }
} }
/* ===================== Two-factor authentication dialog ===================== */
.tfa-dialog-panel {
.mat-mdc-dialog-surface {
background: linear-gradient(160deg, #0b1220 0%, #0f172a 60%, #111c33 100%) !important;
border-radius: 18px !important;
padding: 0 !important;
box-shadow: 0 30px 80px rgb(0 0 0 / 55%) !important;
overflow: hidden;
}
.mat-mdc-dialog-container {
--mdc-dialog-container-color: transparent;
padding: 0 !important;
}
}
.cdk-overlay-pane.tfa-dialog-panel {
border-radius: 18px;
}
.tfa-dialog-backdrop {
background: rgb(2 6 23 / 75%) !important;
backdrop-filter: blur(4px);
}