merge feature/update_code and feature/UI-MFA-Product branch code
This commit is contained in:
Binary file not shown.
@@ -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 </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
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import { CfcfrmsService } from '../cfcfrms.service';
|
|||||||
import { CfrLookupResponse, CfrRecord } from '../models/cfcfrms.models';
|
import { CfrLookupResponse, CfrRecord } from '../models/cfcfrms.models';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'cfcfrms-cfr-lookup',
|
selector: 'mifosx-cfcfrms-cfr-lookup',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
templateUrl: './cfr-lookup.component.html',
|
templateUrl: './cfr-lookup.component.html',
|
||||||
styleUrls: ['./cfr-lookup.component.scss'],
|
styleUrls: ['./cfr-lookup.component.scss'],
|
||||||
|
|||||||
@@ -37,7 +37,7 @@
|
|||||||
<mat-card-header>
|
<mat-card-header>
|
||||||
<mat-card-title>{{ 'labels.menus.Complaint Information' | translate }}</mat-card-title>
|
<mat-card-title>{{ 'labels.menus.Complaint Information' | translate }}</mat-card-title>
|
||||||
<span class="spacer"></span>
|
<span class="spacer"></span>
|
||||||
<cfcfrms-status-badge [status]="complaint.status"></cfcfrms-status-badge>
|
<mifosx-cfcfrms-status-badge [status]="complaint.status"></mifosx-cfcfrms-status-badge>
|
||||||
</mat-card-header>
|
</mat-card-header>
|
||||||
<mat-card-content>
|
<mat-card-content>
|
||||||
<div class="info-grid">
|
<div class="info-grid">
|
||||||
@@ -180,7 +180,7 @@
|
|||||||
<form [formGroup]="actionForm" (ngSubmit)="onSubmitAction()">
|
<form [formGroup]="actionForm" (ngSubmit)="onSubmitAction()">
|
||||||
<div class="current-status">
|
<div class="current-status">
|
||||||
<span class="label">{{ 'labels.text.Current Status' | translate }}:</span>
|
<span class="label">{{ 'labels.text.Current Status' | translate }}:</span>
|
||||||
<cfcfrms-status-badge [status]="complaint.status"></cfcfrms-status-badge>
|
<mifosx-cfcfrms-status-badge [status]="complaint.status"></mifosx-cfcfrms-status-badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<mat-form-field appearance="outline" class="full-width">
|
<mat-form-field appearance="outline" class="full-width">
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import { ConfirmDialogComponent } from '../components/confirm-dialog/confirm-dia
|
|||||||
import { Complaint, ComplaintStatus, TimelineEvent } from '../models/cfcfrms.models';
|
import { Complaint, ComplaintStatus, TimelineEvent } from '../models/cfcfrms.models';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'cfcfrms-complaint-detail',
|
selector: 'mifosx-cfcfrms-complaint-detail',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
|
|||||||
@@ -122,7 +122,7 @@
|
|||||||
<ng-container matColumnDef="status">
|
<ng-container matColumnDef="status">
|
||||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.text.Status' | translate }}</th>
|
<th mat-header-cell *matHeaderCellDef>{{ 'labels.text.Status' | translate }}</th>
|
||||||
<td mat-cell *matCellDef="let row">
|
<td mat-cell *matCellDef="let row">
|
||||||
<cfcfrms-status-badge [status]="row.status"></cfcfrms-status-badge>
|
<mifosx-cfcfrms-status-badge [status]="row.status"></mifosx-cfcfrms-status-badge>
|
||||||
</td>
|
</td>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
<ng-container matColumnDef="createdAt">
|
<ng-container matColumnDef="createdAt">
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ import { Complaint, ComplaintStatus } from '../models/cfcfrms.models';
|
|||||||
type FilterChip = 'ALL' | 'PENDING' | 'ACTIVE' | 'RESOLVED';
|
type FilterChip = 'ALL' | 'PENDING' | 'ACTIVE' | 'RESOLVED';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'cfcfrms-complaint-list',
|
selector: 'mifosx-cfcfrms-complaint-list',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Component, Inject } from '@angular/core';
|
import { Component, Inject, inject } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { MAT_DIALOG_DATA, MatDialogRef, MatDialogModule } from '@angular/material/dialog';
|
import { MAT_DIALOG_DATA, MatDialogRef, MatDialogModule } from '@angular/material/dialog';
|
||||||
import { MatButtonModule } from '@angular/material/button';
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
@@ -19,7 +19,7 @@ export interface ConfirmDialogData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'cfcfrms-confirm-dialog',
|
selector: 'mifosx-cfcfrms-confirm-dialog',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
@@ -46,10 +46,8 @@ export interface ConfirmDialogData {
|
|||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class ConfirmDialogComponent {
|
export class ConfirmDialogComponent {
|
||||||
constructor(
|
public dialogRef = inject(MatDialogRef<ConfirmDialogComponent>);
|
||||||
public dialogRef: MatDialogRef<ConfirmDialogComponent>,
|
public data = inject<ConfirmDialogData>(MAT_DIALOG_DATA);
|
||||||
@Inject(MAT_DIALOG_DATA) public data: ConfirmDialogData
|
|
||||||
) {}
|
|
||||||
|
|
||||||
onCancel(): void {
|
onCancel(): void {
|
||||||
this.dialogRef.close(false);
|
this.dialogRef.close(false);
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { CommonModule } from '@angular/common';
|
|||||||
import { ComplaintStatus } from '../../models/cfcfrms.models';
|
import { ComplaintStatus } from '../../models/cfcfrms.models';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'cfcfrms-status-badge',
|
selector: 'mifosx-cfcfrms-status-badge',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule],
|
imports: [CommonModule],
|
||||||
template: `
|
template: `
|
||||||
|
|||||||
@@ -114,7 +114,7 @@
|
|||||||
<ng-container matColumnDef="status">
|
<ng-container matColumnDef="status">
|
||||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.text.Status' | translate }}</th>
|
<th mat-header-cell *matHeaderCellDef>{{ 'labels.text.Status' | translate }}</th>
|
||||||
<td mat-cell *matCellDef="let row">
|
<td mat-cell *matCellDef="let row">
|
||||||
<cfcfrms-status-badge [status]="row.status"></cfcfrms-status-badge>
|
<mifosx-cfcfrms-status-badge [status]="row.status"></mifosx-cfcfrms-status-badge>
|
||||||
</td>
|
</td>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
<ng-container matColumnDef="createdAt">
|
<ng-container matColumnDef="createdAt">
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Component, OnInit, inject, ViewChild, ElementRef, AfterViewInit } from '@angular/core';
|
import { Component, OnInit, inject, ViewChild, ElementRef } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { RouterModule } from '@angular/router';
|
import { RouterModule } from '@angular/router';
|
||||||
import { MatCardModule } from '@angular/material/card';
|
import { MatCardModule } from '@angular/material/card';
|
||||||
@@ -24,7 +24,7 @@ import { Complaint, DashboardStats, ComplaintStatus } from '../models/cfcfrms.mo
|
|||||||
Chart.register(...registerables);
|
Chart.register(...registerables);
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'cfcfrms-dashboard',
|
selector: 'mifosx-cfcfrms-dashboard',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
@@ -41,7 +41,7 @@ Chart.register(...registerables);
|
|||||||
templateUrl: './cfcfrms-dashboard.component.html',
|
templateUrl: './cfcfrms-dashboard.component.html',
|
||||||
styleUrls: ['./cfcfrms-dashboard.component.scss']
|
styleUrls: ['./cfcfrms-dashboard.component.scss']
|
||||||
})
|
})
|
||||||
export class CfcfrmsDashboardComponent implements OnInit, AfterViewInit {
|
export class CfcfrmsDashboardComponent implements OnInit {
|
||||||
private cfcfrmsService = inject(CfcfrmsService);
|
private cfcfrmsService = inject(CfcfrmsService);
|
||||||
|
|
||||||
stats: DashboardStats;
|
stats: DashboardStats;
|
||||||
@@ -66,8 +66,6 @@ export class CfcfrmsDashboardComponent implements OnInit, AfterViewInit {
|
|||||||
this.loadData();
|
this.loadData();
|
||||||
}
|
}
|
||||||
|
|
||||||
ngAfterViewInit(): void {}
|
|
||||||
|
|
||||||
loadData(): void {
|
loadData(): void {
|
||||||
this.isLoading = true;
|
this.isLoading = true;
|
||||||
this.cfcfrmsService.getStats().subscribe({
|
this.cfcfrmsService.getStats().subscribe({
|
||||||
|
|||||||
@@ -98,7 +98,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="lookup-row">
|
<div class="lookup-row">
|
||||||
<span>{{ 'labels.text.Current Status' | translate }}:</span>
|
<span>{{ 'labels.text.Current Status' | translate }}:</span>
|
||||||
<cfcfrms-status-badge [status]="lookedUpComplaint.status"></cfcfrms-status-badge>
|
<mifosx-cfcfrms-status-badge [status]="lookedUpComplaint.status"></mifosx-cfcfrms-status-badge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import { ConfirmDialogComponent } from '../components/confirm-dialog/confirm-dia
|
|||||||
import { Complaint, EscalationResponse } from '../models/cfcfrms.models';
|
import { Complaint, EscalationResponse } from '../models/cfcfrms.models';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'cfcfrms-escalate',
|
selector: 'mifosx-cfcfrms-escalate',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import { CfcfrmsService } from '../cfcfrms.service';
|
|||||||
import { ConfirmDialogComponent } from '../components/confirm-dialog/confirm-dialog.component';
|
import { ConfirmDialogComponent } from '../components/confirm-dialog/confirm-dialog.component';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'cfcfrms-fmr-submission',
|
selector: 'mifosx-cfcfrms-fmr-submission',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
templateUrl: './fmr-submission.component.html',
|
templateUrl: './fmr-submission.component.html',
|
||||||
styleUrls: ['./fmr-submission.component.scss'],
|
styleUrls: ['./fmr-submission.component.scss'],
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import { CfcfrmsService } from '../cfcfrms.service';
|
|||||||
import { ConfirmDialogComponent } from '../components/confirm-dialog/confirm-dialog.component';
|
import { ConfirmDialogComponent } from '../components/confirm-dialog/confirm-dialog.component';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'cfcfrms-report-fraud-txn',
|
selector: 'mifosx-cfcfrms-report-fraud-txn',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
templateUrl: './report-fraud-txn.component.html',
|
templateUrl: './report-fraud-txn.component.html',
|
||||||
styleUrls: ['./report-fraud-txn.component.scss'],
|
styleUrls: ['./report-fraud-txn.component.scss'],
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import { CfcfrmsService } from '../cfcfrms.service';
|
|||||||
import { ConfirmDialogComponent } from '../components/confirm-dialog/confirm-dialog.component';
|
import { ConfirmDialogComponent } from '../components/confirm-dialog/confirm-dialog.component';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'cfcfrms-report-mule',
|
selector: 'mifosx-cfcfrms-report-mule',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
templateUrl: './report-mule.component.html',
|
templateUrl: './report-mule.component.html',
|
||||||
styleUrls: ['./report-mule.component.scss'],
|
styleUrls: ['./report-mule.component.scss'],
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import { CfcfrmsService } from '../cfcfrms.service';
|
|||||||
import { ConfirmDialogComponent } from '../components/confirm-dialog/confirm-dialog.component';
|
import { ConfirmDialogComponent } from '../components/confirm-dialog/confirm-dialog.component';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'cfcfrms-report-suspect',
|
selector: 'mifosx-cfcfrms-report-suspect',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
templateUrl: './report-suspect.component.html',
|
templateUrl: './report-suspect.component.html',
|
||||||
styleUrls: ['./report-suspect.component.scss'],
|
styleUrls: ['./report-suspect.component.scss'],
|
||||||
|
|||||||
+1
-1
@@ -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;
|
||||||
|
|||||||
+18
-5
@@ -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;
|
||||||
|
|||||||
+69
-18
@@ -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,10 +919,58 @@ 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;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -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];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+154
-11
@@ -114,22 +114,165 @@
|
|||||||
|
|
||||||
<div class="addr-divider"><span>Proof of Address</span></div>
|
<div class="addr-divider"><span>Proof of Address</span></div>
|
||||||
|
|
||||||
|
@if (loadingProofs) {
|
||||||
|
<div class="addr-proof-loading">
|
||||||
|
<fa-icon icon="spinner" [animation]="'spin'"></fa-icon>
|
||||||
|
Loading document types…
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<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>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</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">
|
<div class="addr-grid addr-grid--2">
|
||||||
<mat-form-field appearance="outline" class="addr-ff">
|
<mat-form-field appearance="outline" class="addr-ff">
|
||||||
<mat-label>Address Proof Type</mat-label>
|
<mat-label>{{ selectedAddrProof.documentKeyLabel || 'Document number' }}</mat-label>
|
||||||
<span matPrefix class="addr-prefix"><mat-icon>verified_user</mat-icon></span>
|
|
||||||
<mat-select required formControlName="proofType">
|
|
||||||
@for (pt of proofTypes; track pt) {
|
|
||||||
<mat-option [value]="pt">{{ pt }}</mat-option>
|
|
||||||
}
|
|
||||||
</mat-select>
|
|
||||||
</mat-form-field>
|
|
||||||
<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>
|
<span matPrefix class="addr-prefix"><mat-icon>tag</mat-icon></span>
|
||||||
<input matInput formControlName="proofDocNumber" placeholder="e.g. XXXX XXXX XXXX" />
|
<input
|
||||||
|
matInput
|
||||||
|
formControlName="documentKey"
|
||||||
|
[placeholder]="selectedAddrProof.documentKeyPlaceholder || ''"
|
||||||
|
/>
|
||||||
</mat-form-field>
|
</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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
|||||||
+343
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+124
-11
@@ -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 & Signature
|
Photograph & 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 & Crop
|
<fa-icon icon="camera"></fa-icon> Choose & 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 & Crop
|
<fa-icon icon="crop"></fa-icon> Choose & 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
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|||||||
+2
-2
@@ -180,13 +180,13 @@
|
|||||||
}}</span>
|
}}</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@if (client?.ageYears != null) {
|
@if (client?.ageYears !== null) {
|
||||||
<div class="preview-field">
|
<div class="preview-field">
|
||||||
<span class="preview-label">Age</span>
|
<span class="preview-label">Age</span>
|
||||||
<span class="preview-value">{{ client.ageYears }}y {{ client.ageMonths }}m {{ client.ageDays }}d</span>
|
<span class="preview-value">{{ client.ageYears }}y {{ client.ageMonths }}m {{ client.ageDays }}d</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@if (client?.isMinor != null) {
|
@if (client?.isMinor !== null) {
|
||||||
<div class="preview-field">
|
<div class="preview-field">
|
||||||
<span class="preview-label">Minor</span>
|
<span class="preview-label">Minor</span>
|
||||||
<span class="preview-value">
|
<span class="preview-value">
|
||||||
|
|||||||
@@ -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 || '';
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -168,7 +168,7 @@
|
|||||||
<div class="cv-cell__value">{{ clientViewData.occupation.name }}</div>
|
<div class="cv-cell__value">{{ clientViewData.occupation.name }}</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@if (clientViewData.mobileNo && clientViewData.dipVerified != null) {
|
@if (clientViewData.mobileNo && clientViewData.dipVerified !== null) {
|
||||||
<div class="cv-cell">
|
<div class="cv-cell">
|
||||||
<div class="cv-cell__label">DIP Status</div>
|
<div class="cv-cell__label">DIP Status</div>
|
||||||
<div class="cv-cell__value">
|
<div class="cv-cell__value">
|
||||||
@@ -196,7 +196,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@if (clientViewData.nameVerified != null) {
|
@if (clientViewData.nameVerified !== null) {
|
||||||
<div class="cv-cell">
|
<div class="cv-cell">
|
||||||
<div class="cv-cell__label">Sanctions Screening</div>
|
<div class="cv-cell__label">Sanctions Screening</div>
|
||||||
<div class="cv-cell__value">
|
<div class="cv-cell__value">
|
||||||
@@ -236,13 +236,13 @@
|
|||||||
<div class="cv-cell__value">{{ clientViewData.maritalStatus.name }}</div>
|
<div class="cv-cell__value">{{ clientViewData.maritalStatus.name }}</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@if (clientViewData.monthlyIncome != null && clientViewData.monthlyIncome !== '') {
|
@if (clientViewData.monthlyIncome !== null && clientViewData.monthlyIncome !== '') {
|
||||||
<div class="cv-cell">
|
<div class="cv-cell">
|
||||||
<div class="cv-cell__label">Monthly Income</div>
|
<div class="cv-cell__label">Monthly Income</div>
|
||||||
<div class="cv-cell__value">₹ {{ clientViewData.monthlyIncome | number }}</div>
|
<div class="cv-cell__value">₹ {{ clientViewData.monthlyIncome | number }}</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@if (clientViewData.annualTurnover != null && clientViewData.annualTurnover !== '') {
|
@if (clientViewData.annualTurnover !== null && clientViewData.annualTurnover !== '') {
|
||||||
<div class="cv-cell">
|
<div class="cv-cell">
|
||||||
<div class="cv-cell__label">Annual Turnover</div>
|
<div class="cv-cell__label">Annual Turnover</div>
|
||||||
<div class="cv-cell__value">₹ {{ clientViewData.annualTurnover | number }}</div>
|
<div class="cv-cell__value">₹ {{ clientViewData.annualTurnover | number }}</div>
|
||||||
|
|||||||
+4
-163
@@ -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 };
|
||||||
|
|||||||
@@ -79,7 +79,7 @@
|
|||||||
<div class="nom-field__value">{{ member.dateOfBirth | dateFormat }}</div>
|
<div class="nom-field__value">{{ member.dateOfBirth | dateFormat }}</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@if (member.isDependent != null) {
|
@if (member.isDependent !== null) {
|
||||||
<div class="nom-field">
|
<div class="nom-field">
|
||||||
<div class="nom-field__label">Dependent</div>
|
<div class="nom-field__label">Dependent</div>
|
||||||
<div class="nom-field__value">{{ member.isDependent | yesNo }}</div>
|
<div class="nom-field__value">{{ member.isDependent | yesNo }}</div>
|
||||||
|
|||||||
@@ -59,7 +59,7 @@
|
|||||||
@for (acc of selectedGroup.accounts; track acc.id) {
|
@for (acc of selectedGroup.accounts; track acc.id) {
|
||||||
<mat-option [value]="acc.id"
|
<mat-option [value]="acc.id"
|
||||||
>{{ acc.productName }} — {{ acc.accountNo }}
|
>{{ acc.productName }} — {{ acc.accountNo }}
|
||||||
@if (acc.accountBalance != null) {
|
@if (acc.accountBalance !== null) {
|
||||||
({{ acc.accountBalance | number: '1.2-2' }})
|
({{ acc.accountBalance | number: '1.2-2' }})
|
||||||
}
|
}
|
||||||
</mat-option>
|
</mat-option>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -52,7 +52,7 @@
|
|||||||
<div class="pd-field__value">{{ clientViewData.dateOfBirth | dateFormat: 'DD-MMMM-YYYY' }}</div>
|
<div class="pd-field__value">{{ clientViewData.dateOfBirth | dateFormat: 'DD-MMMM-YYYY' }}</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@if (clientViewData.ageYears != null) {
|
@if (clientViewData.ageYears !== null) {
|
||||||
<div class="pd-field">
|
<div class="pd-field">
|
||||||
<div class="pd-field__label">Age</div>
|
<div class="pd-field__label">Age</div>
|
||||||
<div class="pd-field__value">
|
<div class="pd-field__value">
|
||||||
@@ -96,7 +96,7 @@
|
|||||||
<div class="pd-field__value">{{ clientViewData.socialCategory.name }}</div>
|
<div class="pd-field__value">{{ clientViewData.socialCategory.name }}</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@if (clientViewData.isMinor != null) {
|
@if (clientViewData.isMinor !== null) {
|
||||||
<div class="pd-field">
|
<div class="pd-field">
|
||||||
<div class="pd-field__label">Minor</div>
|
<div class="pd-field__label">Minor</div>
|
||||||
<div class="pd-field__value">
|
<div class="pd-field__value">
|
||||||
@@ -366,7 +366,7 @@
|
|||||||
<div class="pd-field">
|
<div class="pd-field">
|
||||||
<div class="pd-field__label">Monthly Income</div>
|
<div class="pd-field__label">Monthly Income</div>
|
||||||
<div class="pd-field__value">
|
<div class="pd-field__value">
|
||||||
@if (clientViewData.monthlyIncome != null && clientViewData.monthlyIncome !== '') {
|
@if (clientViewData.monthlyIncome !== null && clientViewData.monthlyIncome !== '') {
|
||||||
<span class="monospace">₹ {{ clientViewData.monthlyIncome | number }}</span>
|
<span class="monospace">₹ {{ clientViewData.monthlyIncome | number }}</span>
|
||||||
} @else {
|
} @else {
|
||||||
-
|
-
|
||||||
@@ -376,7 +376,7 @@
|
|||||||
<div class="pd-field">
|
<div class="pd-field">
|
||||||
<div class="pd-field__label">Annual Turnover (Last FY)</div>
|
<div class="pd-field__label">Annual Turnover (Last FY)</div>
|
||||||
<div class="pd-field__value">
|
<div class="pd-field__value">
|
||||||
@if (clientViewData.annualTurnover != null && clientViewData.annualTurnover !== '') {
|
@if (clientViewData.annualTurnover !== null && clientViewData.annualTurnover !== '') {
|
||||||
<span class="monospace">₹ {{ clientViewData.annualTurnover | number }}</span>
|
<span class="monospace">₹ {{ clientViewData.annualTurnover | number }}</span>
|
||||||
} @else {
|
} @else {
|
||||||
-
|
-
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
+3
-3
@@ -510,7 +510,7 @@
|
|||||||
<div class="preview-dorm-step__body">
|
<div class="preview-dorm-step__body">
|
||||||
<span class="preview-dorm-step__label">Inactive</span>
|
<span class="preview-dorm-step__label">Inactive</span>
|
||||||
<span class="preview-dorm-step__days">
|
<span class="preview-dorm-step__days">
|
||||||
@if (currentAccountProductTemplate?.daysToInactive != null) {
|
@if (currentAccountProductTemplate?.daysToInactive !== null) {
|
||||||
After {{ currentAccountProductTemplate.daysToInactive }} days of no transaction
|
After {{ currentAccountProductTemplate.daysToInactive }} days of no transaction
|
||||||
} @else {
|
} @else {
|
||||||
Not configured
|
Not configured
|
||||||
@@ -524,7 +524,7 @@
|
|||||||
<div class="preview-dorm-step__body">
|
<div class="preview-dorm-step__body">
|
||||||
<span class="preview-dorm-step__label">Dormant</span>
|
<span class="preview-dorm-step__label">Dormant</span>
|
||||||
<span class="preview-dorm-step__days">
|
<span class="preview-dorm-step__days">
|
||||||
@if (currentAccountProductTemplate?.daysToDormancy != null) {
|
@if (currentAccountProductTemplate?.daysToDormancy !== null) {
|
||||||
{{ currentAccountProductTemplate.daysToDormancy }} days after inactive
|
{{ currentAccountProductTemplate.daysToDormancy }} days after inactive
|
||||||
} @else {
|
} @else {
|
||||||
Not configured
|
Not configured
|
||||||
@@ -538,7 +538,7 @@
|
|||||||
<div class="preview-dorm-step__body">
|
<div class="preview-dorm-step__body">
|
||||||
<span class="preview-dorm-step__label">Escheat</span>
|
<span class="preview-dorm-step__label">Escheat</span>
|
||||||
<span class="preview-dorm-step__days">
|
<span class="preview-dorm-step__days">
|
||||||
@if (currentAccountProductTemplate?.daysToEscheat != null) {
|
@if (currentAccountProductTemplate?.daysToEscheat !== null) {
|
||||||
{{ currentAccountProductTemplate.daysToEscheat }} days after dormant
|
{{ currentAccountProductTemplate.daysToEscheat }} days after dormant
|
||||||
} @else {
|
} @else {
|
||||||
Not configured
|
Not configured
|
||||||
|
|||||||
+3
-3
@@ -237,7 +237,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Maturity Summary (all FD types) -->
|
<!-- Maturity Summary (all FD types) -->
|
||||||
@if (matchResult.maturityAmount != null) {
|
@if (matchResult.maturityAmount !== null) {
|
||||||
<div class="fdrc-maturity-card">
|
<div class="fdrc-maturity-card">
|
||||||
<div class="fdrc-maturity-card__header">
|
<div class="fdrc-maturity-card__header">
|
||||||
<i class="fas fa-coins"></i>
|
<i class="fas fa-coins"></i>
|
||||||
@@ -260,7 +260,7 @@
|
|||||||
<span class="fdrc-maturity-row__label">Interest Earned</span>
|
<span class="fdrc-maturity-row__label">Interest Earned</span>
|
||||||
<span class="fdrc-maturity-row__value">₹ {{ matchResult.interestEarned | number: '1.2-2' }}</span>
|
<span class="fdrc-maturity-row__value">₹ {{ matchResult.interestEarned | number: '1.2-2' }}</span>
|
||||||
</div>
|
</div>
|
||||||
@if (isSpecial && matchResult.estimatedMonthlyPayout != null) {
|
@if (isSpecial && matchResult.estimatedMonthlyPayout !== null) {
|
||||||
<div class="fdrc-maturity-row">
|
<div class="fdrc-maturity-row">
|
||||||
<span class="fdrc-maturity-row__label">Monthly Payout</span>
|
<span class="fdrc-maturity-row__label">Monthly Payout</span>
|
||||||
<span class="fdrc-maturity-row__value fdrc-maturity-row__value--highlight">
|
<span class="fdrc-maturity-row__value fdrc-maturity-row__value--highlight">
|
||||||
@@ -320,7 +320,7 @@
|
|||||||
<td mat-cell *matCellDef="let slab" class="fdrc-td fdrc-td--period">
|
<td mat-cell *matCellDef="let slab" class="fdrc-td fdrc-td--period">
|
||||||
<span class="fdrc-period-range">
|
<span class="fdrc-period-range">
|
||||||
{{ slab.fromPeriod }}
|
{{ slab.fromPeriod }}
|
||||||
@if (slab.toPeriod != null) {
|
@if (slab.toPeriod !== null) {
|
||||||
– {{ slab.toPeriod }}
|
– {{ slab.toPeriod }}
|
||||||
} @else {
|
} @else {
|
||||||
+
|
+
|
||||||
|
|||||||
@@ -238,8 +238,8 @@
|
|||||||
<strong>{{ c.achievedPct.toFixed(2) }}%</strong>
|
<strong>{{ c.achievedPct.toFixed(2) }}%</strong>
|
||||||
</td>
|
</td>
|
||||||
<td class="ta-r">
|
<td class="ta-r">
|
||||||
<span *ngIf="c.targetPct != null">{{ c.targetPct }}%</span>
|
<span *ngIf="c.targetPct !== null">{{ c.targetPct }}%</span>
|
||||||
<span *ngIf="c.targetPct == null" class="muted">—</span>
|
<span *ngIf="c.targetPct === null" class="muted">—</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="ta-c">
|
<td class="ta-c">
|
||||||
<span class="status-pill" [ngClass]="achieveStatus(c.achievedPct, c.targetPct)">
|
<span class="status-pill" [ngClass]="achieveStatus(c.achievedPct, c.targetPct)">
|
||||||
@@ -428,8 +428,8 @@
|
|||||||
<strong>{{ c.achievedPct.toFixed(2) }}%</strong>
|
<strong>{{ c.achievedPct.toFixed(2) }}%</strong>
|
||||||
</td>
|
</td>
|
||||||
<td class="ta-r">
|
<td class="ta-r">
|
||||||
<span *ngIf="c.targetPct != null">{{ c.targetPct }}%</span>
|
<span *ngIf="c.targetPct !== null">{{ c.targetPct }}%</span>
|
||||||
<span *ngIf="c.targetPct == null" class="muted">—</span>
|
<span *ngIf="c.targetPct === null" class="muted">—</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="ta-c">
|
<td class="ta-c">
|
||||||
<span class="status-pill" [ngClass]="achieveStatus(c.achievedPct, c.targetPct)">
|
<span class="status-pill" [ngClass]="achieveStatus(c.achievedPct, c.targetPct)">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
<fa-icon icon="shield-alt"></fa-icon>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>
|
</p>
|
||||||
|
|
||||||
<mat-divider></mat-divider>
|
<!-- Step 1 — Pick a delivery method -->
|
||||||
|
|
||||||
<!-- Select delivery method to receive OTP -->
|
|
||||||
@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="">
|
||||||
<form class="two-factor-auth-form" [formGroup]="twoFactorAuthenticationDeliveryMethodForm" (ngSubmit)="requestOTP()">
|
<mat-radio-group class="tfa-methods__group" formControlName="twoFactorAuthenticationDeliveryMethod">
|
||||||
<mat-radio-group class="radio-group-spacing" formControlName="twoFactorAuthenticationDeliveryMethod">
|
@for (method of twoFactorAuthenticationDeliveryMethods; track method.name) {
|
||||||
@for (
|
<div
|
||||||
twoFactorAuthenticationDeliveryMethod of twoFactorAuthenticationDeliveryMethods;
|
class="tfa-method"
|
||||||
track twoFactorAuthenticationDeliveryMethod
|
role="radio"
|
||||||
) {
|
tabindex="0"
|
||||||
<mat-radio-button [value]="twoFactorAuthenticationDeliveryMethod">
|
[attr.aria-checked]="
|
||||||
{{ 'labels.inputs.Send' | translate }} {{ twoFactorAuthenticationDeliveryMethod.name }}
|
twoFactorAuthenticationDeliveryMethodForm.value.twoFactorAuthenticationDeliveryMethod === method
|
||||||
{{ 'labels.inputs.to' | translate }} {{ twoFactorAuthenticationDeliveryMethod.target }}
|
"
|
||||||
</mat-radio-button>
|
[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>
|
</mat-radio-group>
|
||||||
<button mat-raised-button color="primary" [disabled]="!twoFactorAuthenticationDeliveryMethodForm.valid || loading">
|
</div>
|
||||||
{{ 'labels.buttons.Request OTP' | translate }}
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="tfa-btn tfa-btn--primary"
|
||||||
|
[disabled]="!twoFactorAuthenticationDeliveryMethodForm.valid || loading"
|
||||||
|
>
|
||||||
@if (loading) {
|
@if (loading) {
|
||||||
<mat-spinner [diameter]="20"></mat-spinner>
|
<mat-spinner diameter="20"></mat-spinner>
|
||||||
|
<span>Sending…</span>
|
||||||
|
} @else {
|
||||||
|
<fa-icon icon="paper-plane"></fa-icon>
|
||||||
|
<span>{{ 'labels.buttons.Request OTP' | translate }}</span>
|
||||||
}
|
}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</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 {
|
||||||
|
<fa-icon icon="check-circle"></fa-icon>
|
||||||
|
<span>{{ 'labels.buttons.Validate OTP' | translate }}</span>
|
||||||
}
|
}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" mat-button (click)="resendOTP()" [disabled]="loading || resendOTPLoading">
|
|
||||||
{{ 'labels.buttons.Resend OTP' | translate }}
|
<button
|
||||||
|
type="button"
|
||||||
|
class="tfa-btn tfa-btn--ghost"
|
||||||
|
(click)="resendOTP()"
|
||||||
|
[disabled]="loading || resendOTPLoading"
|
||||||
|
>
|
||||||
@if (resendOTPLoading) {
|
@if (resendOTPLoading) {
|
||||||
<mat-spinner [diameter]="20"></mat-spinner>
|
<mat-spinner diameter="18"></mat-spinner>
|
||||||
|
<span>Resending…</span>
|
||||||
|
} @else {
|
||||||
|
<fa-icon icon="redo"></fa-icon>
|
||||||
|
<span>{{ 'labels.buttons.Resend OTP' | translate }}</span>
|
||||||
}
|
}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
}
|
}
|
||||||
|
</div>
|
||||||
|
|||||||
+300
-187
@@ -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:
|
||||||
mat-radio-button {
|
0 6px 20px rgb(37 99 235 / 35%),
|
||||||
margin-bottom: 0.25rem;
|
inset 0 1px 0 rgb(255 255 255 / 25%);
|
||||||
|
margin-bottom: 14px;
|
||||||
&:last-child {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Style radio buttons to match Material Design 3
|
.tfa-title {
|
||||||
::ng-deep {
|
margin: 0 0 6px;
|
||||||
.mdc-form-field {
|
text-align: center;
|
||||||
font-size: 0.95rem;
|
font-size: 18px;
|
||||||
color: var(--md-sys-color-on-surface, #1a1c1e);
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
letter-spacing: 0.2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mdc-radio__outer-circle {
|
.tfa-sub {
|
||||||
border-color: var(--md-sys-color-on-surface-variant, #44474e);
|
margin: 0 0 20px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 13px;
|
||||||
|
color: rgb(255 255 255 / 65%);
|
||||||
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mdc-radio--selected .mdc-radio__outer-circle,
|
/* ──────── Form layout ──────── */
|
||||||
.mdc-radio--selected .mdc-radio__inner-circle {
|
.tfa-form {
|
||||||
border-color: var(--md-sys-color-primary, #1074b9);
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ──────── Step 1 — delivery methods ──────── */
|
||||||
|
.tfa-methods__group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tfa-method {
|
||||||
|
display: flex;
|
||||||
|
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%);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-selected {
|
||||||
|
border-color: #60a5fa;
|
||||||
|
background: rgb(96 165 250 / 12%);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 0 0 1px #60a5fa,
|
||||||
|
0 6px 18px rgb(37 99 235 / 28%);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__icon {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgb(255 255 255 / 8%);
|
||||||
|
color: #93c5fd;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 14px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-selected &__icon {
|
||||||
|
background: #1d4ed8;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__name {
|
||||||
|
font-size: 13px;
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== OTP Input Field =====
|
/* ──────── Step 2 — context strip ──────── */
|
||||||
.two-factor-auth-input {
|
.tfa-context {
|
||||||
width: 100%;
|
display: flex;
|
||||||
max-width: 320px;
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgb(255 255 255 / 5%);
|
||||||
|
border: 1px solid rgb(255 255 255 / 10%);
|
||||||
|
|
||||||
[matPrefix] {
|
> fa-icon {
|
||||||
padding-left: 1rem;
|
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 {
|
fa-icon {
|
||||||
margin-right: 0.75rem;
|
font-size: 10px;
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
::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 =====
|
/* ──────── Step 2 — OTP input ──────── */
|
||||||
.two-factor-auth-button,
|
.tfa-otp {
|
||||||
[mat-raised-button],
|
display: flex;
|
||||||
[mat-button] {
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tfa-otp__input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 320px;
|
max-width: 320px;
|
||||||
height: 48px;
|
height: 60px;
|
||||||
border-radius: 24px;
|
text-align: center;
|
||||||
font-size: 0.95rem;
|
border: 1px solid rgb(255 255 255 / 18%);
|
||||||
font-weight: 500;
|
background: rgb(255 255 255 / 8%);
|
||||||
letter-spacing: 0.02em;
|
border-radius: 14px;
|
||||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
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;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 0.5rem;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
[mat-raised-button] {
|
strong {
|
||||||
background: var(--md-sys-color-primary, #1074b9);
|
color: #fff;
|
||||||
color: var(--md-sys-color-on-primary, #fff);
|
font-weight: 700;
|
||||||
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] {
|
/* ──────── Buttons ──────── */
|
||||||
color: var(--md-sys-color-primary, #1074b9);
|
.tfa-btn {
|
||||||
background: transparent;
|
display: inline-flex;
|
||||||
|
|
||||||
&:hover:not([disabled]) {
|
|
||||||
background: rgb(16 116 185 / 8%);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
mat-spinner {
|
|
||||||
display: inline-block;
|
|
||||||
margin-left: 0.5rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== Radio Group Spacing =====
|
|
||||||
.radio-group-spacing {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.75rem;
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0.5rem 0;
|
height: 48px;
|
||||||
margin-bottom: 0.5rem;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== Animations =====
|
&:disabled {
|
||||||
@keyframes fade-in-up {
|
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';
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
-3
@@ -418,7 +418,7 @@
|
|||||||
<div class="preview-dorm-step__body">
|
<div class="preview-dorm-step__body">
|
||||||
<span class="preview-dorm-step__label">Inactive</span>
|
<span class="preview-dorm-step__label">Inactive</span>
|
||||||
<span class="preview-dorm-step__days">
|
<span class="preview-dorm-step__days">
|
||||||
@if (savingsAccountProductTemplate?.daysToInactiveFromProduct != null) {
|
@if (savingsAccountProductTemplate?.daysToInactiveFromProduct !== null) {
|
||||||
After {{ savingsAccountProductTemplate.daysToInactiveFromProduct }} days of no transaction
|
After {{ savingsAccountProductTemplate.daysToInactiveFromProduct }} days of no transaction
|
||||||
} @else {
|
} @else {
|
||||||
Not configured
|
Not configured
|
||||||
@@ -432,7 +432,7 @@
|
|||||||
<div class="preview-dorm-step__body">
|
<div class="preview-dorm-step__body">
|
||||||
<span class="preview-dorm-step__label">Dormant</span>
|
<span class="preview-dorm-step__label">Dormant</span>
|
||||||
<span class="preview-dorm-step__days">
|
<span class="preview-dorm-step__days">
|
||||||
@if (savingsAccountProductTemplate?.daysToDormancyFromProduct != null) {
|
@if (savingsAccountProductTemplate?.daysToDormancyFromProduct !== null) {
|
||||||
{{ savingsAccountProductTemplate.daysToDormancyFromProduct }} days after inactive
|
{{ savingsAccountProductTemplate.daysToDormancyFromProduct }} days after inactive
|
||||||
} @else {
|
} @else {
|
||||||
Not configured
|
Not configured
|
||||||
@@ -446,7 +446,7 @@
|
|||||||
<div class="preview-dorm-step__body">
|
<div class="preview-dorm-step__body">
|
||||||
<span class="preview-dorm-step__label">Escheat</span>
|
<span class="preview-dorm-step__label">Escheat</span>
|
||||||
<span class="preview-dorm-step__days">
|
<span class="preview-dorm-step__days">
|
||||||
@if (savingsAccountProductTemplate?.daysToEscheatFromProduct != null) {
|
@if (savingsAccountProductTemplate?.daysToEscheatFromProduct !== null) {
|
||||||
{{ savingsAccountProductTemplate.daysToEscheatFromProduct }} days after dormant
|
{{ savingsAccountProductTemplate.daysToEscheatFromProduct }} days after dormant
|
||||||
} @else {
|
} @else {
|
||||||
Not configured
|
Not configured
|
||||||
|
|||||||
+1
-1
@@ -97,7 +97,7 @@
|
|||||||
</mat-checkbox>
|
</mat-checkbox>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (savingsAccountProductTemplate?.minBalanceForInterestCalculation != null) {
|
@if (savingsAccountProductTemplate?.minBalanceForInterestCalculation !== null) {
|
||||||
<mat-form-field appearance="outline" class="sps-field sps-field--compact">
|
<mat-form-field appearance="outline" class="sps-field sps-field--compact">
|
||||||
<mat-label>{{ 'labels.inputs.Balance Required for Interest Calculation' | translate }}</mat-label>
|
<mat-label>{{ 'labels.inputs.Balance Required for Interest Calculation' | translate }}</mat-label>
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -18,11 +18,12 @@ import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
|
|||||||
})
|
})
|
||||||
export class SupersetService {
|
export class SupersetService {
|
||||||
private sanitizer = inject(DomSanitizer);
|
private sanitizer = inject(DomSanitizer);
|
||||||
|
private httpBackend = inject(HttpBackend);
|
||||||
private supersetHttp: HttpClient;
|
private supersetHttp: HttpClient;
|
||||||
|
|
||||||
constructor(httpBackend: HttpBackend) {
|
constructor() {
|
||||||
// Bypass Fineract interceptors for Superset API calls
|
// Bypass Fineract interceptors for Superset API calls
|
||||||
this.supersetHttp = new HttpClient(httpBackend);
|
this.supersetHttp = new HttpClient(this.httpBackend);
|
||||||
}
|
}
|
||||||
|
|
||||||
get supersetUrl(): string {
|
get supersetUrl(): string {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user