diff --git a/src/app/access-control/access-control-routing.module.ts b/src/app/access-control/access-control-routing.module.ts new file mode 100644 index 000000000..a3f538590 --- /dev/null +++ b/src/app/access-control/access-control-routing.module.ts @@ -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 {} diff --git a/src/app/access-control/access-control.component.html b/src/app/access-control/access-control.component.html new file mode 100644 index 000000000..c8b1dcec3 --- /dev/null +++ b/src/app/access-control/access-control.component.html @@ -0,0 +1,843 @@ + + +
+ +
+
+
+

Access Control · Multi-Factor Authentication

+

+ Pick a method to set up. The factor stays disabled until verification or registration completes for the + signed-in user {{ username }}. +

+
+
+ {{ anyMethodActive ? 'lock' : 'lock_open' }} + {{ anyMethodActive ? 'MFA Active' : 'MFA Off' }} +
+
+ + + @if (!selectedMethod) { +
+
+
add_task
+
+

Select an authentication method

+

+ Choose how you want to confirm sign-in. Only one method may be active at a time. +

+
+
+ + @if (activeMethod) { +
+ info + + {{ + activeMethod === 'sms' + ? 'SMS OTP' + : activeMethod === 'email' + ? 'Email OTP' + : activeMethod === 'totp' + ? 'Authenticator App' + : 'Hardware Token' + }} + is currently active. Disable it first to switch to a different method. + +
+ } + +
+ + + + + + + + + + + +
+ +
+
+ @if (pickedMethod) { + check_circle + + {{ methodLabel(pickedMethod) }} selected. Continue to set it up. + + } @else { + radio_button_unchecked + Pick a method above to continue. + } +
+ +
+
+ } + + + @if (selectedMethod) { +
+ + + + @if (selectedMethod === 'sms') { +
+
smartphone
+
+

SMS OTP

+ Type · SMS +
+ + {{ sms.verified ? 'check_circle' : 'cancel' }} + {{ sms.verified ? 'Verified' : 'Not Verified' }} + +
+

Verify a 10-digit Indian mobile number with a one-time passcode.

+ + +
    +
  1. + 1 +
    + Enter mobile + 10-digit Indian number +
    +
  2. +
  3. + 2 +
    + Verify OTP + 6-digit code +
    +
  4. +
  5. + 3 +
    + Activated + Ready to use +
    +
  6. +
+ +
+ @if (sms.busy) { + + } + @if (!sms.destinationConfirmed) { +
+
+
Step 1 · Mobile number
+

We'll send a 6-digit code to this number. Standard SMS rates may apply.

+ @if (sms.destination && profileLoaded) { +
+ person_pin + Prefilled from your profile. Update your profile to change it. +
+ } +
+ + Mobile number + +91  + + lock + @if (sms.errorMsg) { + {{ sms.errorMsg }} + } + + +
+
+ +
+ } @else if (!sms.verified) { +
+
+
Step 2 · Enter OTP
+
+ smartphone + Code sent to +91 {{ sms.destination }} +
+
+ + 6-digit OTP + + + @if (sms.resendIn > 0) { + Resend in {{ sms.resendIn }}s + } @else { + Resend OTP + } + + + +
+ @if (sms.errorMsg) { +
+ error_outline + {{ sms.errorMsg }} +
+ } +
+ +
+ } @else { +
+ check_circle +
+ SMS OTP active + OTP will be sent to +91 {{ sms.destination }} on every sign-in. +
+ +
+ } +
+ } + + + @if (selectedMethod === 'email') { +
+
mail
+
+

Email OTP

+ Type · EMAIL +
+ + {{ email.verified ? 'check_circle' : 'cancel' }} + {{ email.verified ? 'Verified' : 'Not Verified' }} + +
+

Verify your email address with a one-time passcode.

+ + +
    +
  1. + 1 +
    + Enter email + Valid email address +
    +
  2. +
  3. + 2 +
    + Verify OTP + 6-digit code +
    +
  4. +
  5. + 3 +
    + Activated + Ready to use +
    +
  6. +
+ +
+ @if (email.busy) { + + } + @if (!email.destinationConfirmed) { +
+
+
Step 1 · Email address
+

+ We'll send a 6-digit code to this address. Check your spam folder if not received. +

+ @if (email.destination && profileLoaded) { +
+ person_pin + Prefilled from your profile. Update your profile to change it. +
+ } +
+ + Email address + alternate_email + + lock + @if (email.errorMsg) { + {{ email.errorMsg }} + } + + +
+
+ +
+ } @else if (!email.verified) { +
+
+
Step 2 · Enter OTP
+
+ mail + Code sent to {{ email.destination }} +
+
+ + 6-digit OTP + + + @if (email.resendIn > 0) { + Resend in {{ email.resendIn }}s + } @else { + Resend OTP + } + + + +
+ @if (email.errorMsg) { +
+ error_outline + {{ email.errorMsg }} +
+ } +
+ +
+ } @else { +
+ check_circle +
+ Email OTP active + OTP will be sent to {{ email.destination }} on every sign-in. +
+ +
+ } +
+ } + + + @if (selectedMethod === 'totp') { +
+
qr_code_2
+
+

Authenticator App

+ Type · TOTP +
+ + {{ totp.verified ? 'check_circle' : 'cancel' }} + {{ totp.verified ? 'Registered' : 'Not Registered' }} + +
+

+ Generate a key, scan it in your authenticator app, then enter the 6-digit code to register this device. +

+ +
+ @if (!totp.secret && !totp.verified) { +
+ qr_code_scanner +
+ No authenticator linked yet +

Generate a setup key, scan it in your TOTP app, then verify the 6-digit code shown there.

+
+ +
+ } @else if (totp.secret && !totp.verified) { +
+
+
+
+ @for (row of totpQrCells; track $index) { +
+ @for (cell of row; track $index) { + + } +
+ } +
+
+

Scan in your authenticator app

+
+
+
+
+ 1 +
Open Google Authenticator (or any TOTP app) and tap “Add account”.
+
+
+ 2 +
+ Scan the code on the left, or enter the secret manually. +
+ {{ totp.secret }} + +
+
+
+
+ 3 +
Enter the 6-digit code shown by your app to verify and register this device.
+
+
+ + + Authenticator code + + @if (totp.errorMsg) { + {{ totp.errorMsg }} + } + +
+ + +
+
+
+ } @else { +
+ check_circle +
+ Authenticator app registered + Sign-in will require a 6-digit TOTP code from your app. +
+ +
+ } +
+ } + + + @if (selectedMethod === 'hardware') { +
+
memory
+
+

Hardware Token

+ Type · TOKEN +
+ + {{ methodStatus('hardware').ok ? 'check_circle' : 'cancel' }} + {{ methodStatus('hardware').label }} + +
+

Choose a device kind, then connect & register it.

+ +
+ @if (!selectedHwKind) { + +
+ @for (item of hardware; track item.kind) { +
+
+ {{ item.icon }} +
+
+
+ {{ item.title }} + + {{ item.registered ? 'check_circle' : 'radio_button_unchecked' }} + {{ item.registered ? 'Registered' : 'Not Registered' }} + +
+

{{ item.description }}

+ @if (item.registered) { +
+ tag {{ item.serial }} + schedule {{ item.registeredAt }} +
+ } +
+
+ +
+
+ } +
+ } @else if (currentHwItem) { + + + +
+
+
+ {{ currentHwItem.icon }} + @if (currentHwItem.busy) { + + } + @if (currentHwItem.registered) { + check + } +
+
+
+ +
+

+ {{ currentHwItem.title }} + + {{ currentHwItem.registered ? 'check_circle' : 'cancel' }} + {{ currentHwItem.registered ? 'Registered' : currentHwItem.busy ? 'Detecting…' : 'Not Registered' }} + +

+

{{ currentHwItem.description }}

+ +
    +
  1. + {{ + currentHwItem.busy || currentHwItem.registered ? 'check_circle' : 'circle' + }} + Connect / insert the device +
  2. +
  3. + {{ + currentHwItem.registered ? 'check_circle' : currentHwItem.busy ? 'autorenew' : 'circle' + }} + Detect & read serial number +
  4. +
  5. + {{ currentHwItem.registered ? 'check_circle' : 'circle' }} + Bind device to {{ username }} +
  6. +
+ + @if (currentHwItem.registered) { +
+
+ Serial + {{ currentHwItem.serial }} +
+
+ Registered on + {{ currentHwItem.registeredAt }} +
+
+ } + +
+ @if (!currentHwItem.registered) { + + } @else { + + } +
+
+
+ } +
+ } +
+ } +
diff --git a/src/app/access-control/access-control.component.scss b/src/app/access-control/access-control.component.scss new file mode 100644 index 000000000..d2abc00a2 --- /dev/null +++ b/src/app/access-control/access-control.component.scss @@ -0,0 +1,1564 @@ +/** + * 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/. + */ + +.ac-container { + padding: 20px 24px 30px; + max-width: 1180px; + margin: 0 auto; +} + +/* ───────────────────────── Page header ───────────────────────── */ +.ac-page-head { + display: flex; + align-items: center; + gap: 16px; + padding: 20px 24px; + border-radius: 16px; + background: linear-gradient(135deg, #eff6ff 0%, #fff 60%); + border: 1px solid #dbeafe; + margin-bottom: 22px; + + &__icon { + width: 50px; + height: 50px; + border-radius: 14px; + background: #1e40af; + color: #fff; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 22px; + flex-shrink: 0; + } + + &__text { + flex: 1; + min-width: 0; + + h2 { + margin: 0 0 4px; + font-size: 18px; + font-weight: 700; + color: #0f172a; + } + + p { + margin: 0; + font-size: 13px; + color: #475569; + line-height: 1.55; + } + + strong { + color: #1e40af; + } + } + + &__status { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 14px; + border-radius: 999px; + background: #fee2e2; + color: #b91c1c; + border: 1px solid #fecaca; + font-size: 12px; + font-weight: 700; + letter-spacing: 0.4px; + text-transform: uppercase; + flex-shrink: 0; + + mat-icon { + font-size: 16px; + width: 16px; + height: 16px; + } + + &.is-on { + background: #dcfce7; + color: #166534; + border-color: #bbf7d0; + } + } +} + +/* ───────────────────────── Section headings ───────────────────────── */ +.ac-section { + margin-top: 18px; + + &__head { + display: flex; + align-items: center; + gap: 14px; + margin-bottom: 14px; + } + + &__num { + width: 36px; + height: 36px; + border-radius: 999px; + background: #1e40af; + color: #fff; + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + + mat-icon { + font-size: 18px; + width: 18px; + height: 18px; + } + } + + &__title { + margin: 0; + font-size: 15px; + font-weight: 700; + color: #0f172a; + } + + &__sub { + margin: 4px 0 0; + font-size: 12.5px; + color: #64748b; + line-height: 1.5; + } +} + +/* ───────────────────────── Common pills ───────────────────────── */ +.ac-state-pill { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 3px 10px; + border-radius: 999px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.4px; + text-transform: uppercase; + white-space: nowrap; + + mat-icon { + font-size: 13px; + width: 13px; + height: 13px; + } + + &.ok { + background: #dcfce7; + color: #166534; + border: 1px solid #bbf7d0; + } + + &.warn { + background: #f1f5f9; + color: #475569; + border: 1px solid #e2e8f0; + } +} + +.ac-type-pill { + display: inline-block; + padding: 1px 8px; + border-radius: 999px; + background: #eef2ff; + color: #3730a3; + border: 1px solid #c7d2fe; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.5px; + text-transform: uppercase; +} + +/* ───────────────────────── Active-method banner ───────────────────────── */ +.ac-active-banner { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 16px; + margin-bottom: 16px; + border: 1px solid #fde68a; + background: #fffbeb; + border-radius: 12px; + color: #78350f; + font-size: 13px; + line-height: 1.5; + + mat-icon { + color: #d97706; + font-size: 18px; + width: 18px; + height: 18px; + flex-shrink: 0; + } + + strong { + color: #92400e; + } +} + +/* ───────────────────────── Method picker tiles ───────────────────────── */ +.ac-picker { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + gap: 16px; +} + +.ac-tile { + position: relative; + text-align: left; + padding: 18px 18px 22px; + border-radius: 14px; + border: 1px solid #e2e8f0; + background: #fff; + cursor: pointer; + transition: + border-color 0.18s, + box-shadow 0.18s, + transform 0.18s, + opacity 0.18s; + display: flex; + flex-direction: column; + gap: 8px; + + &:hover:not(:disabled) { + border-color: #93c5fd; + box-shadow: 0 6px 20px rgb(37 99 235 / 12%); + transform: translateY(-1px); + } + + &:focus-visible { + outline: 2px solid #1d4ed8; + outline-offset: 2px; + } + + &--locked { + cursor: not-allowed; + opacity: 0.55; + background: #f8fafc; + border-style: dashed; + + &:hover { + transform: none; + box-shadow: none; + border-color: #e2e8f0; + } + } + + &--selected { + border-color: #16a34a; + background: linear-gradient(180deg, #f0fdf4 0%, #fff 70%); + box-shadow: + inset 0 0 0 1px #16a34a, + 0 6px 20px rgb(22 163 74 / 14%); + } + + &--active { + /* Verified factor — slightly stronger green tint */ + background: linear-gradient(180deg, #ecfdf5 0%, #f0fdf4 70%); + } + + &__radio { + width: 24px; + height: 24px; + border-radius: 999px; + border: 2px solid #cbd5e1; + background: #fff; + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + transition: + background 0.15s, + border-color 0.15s, + transform 0.15s; + + mat-icon { + font-size: 14px; + width: 14px; + height: 14px; + color: #fff; + } + + &.is-on { + background: #16a34a; + border-color: #16a34a; + transform: scale(1.06); + box-shadow: 0 0 0 4px rgb(22 163 74 / 18%); + } + + &.is-locked { + background: #e2e8f0; + border-color: #cbd5e1; + + mat-icon { + color: #64748b; + } + } + } + + &__top { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + } + + &__icon { + width: 44px; + height: 44px; + border-radius: 12px; + display: inline-flex; + align-items: center; + justify-content: center; + + mat-icon { + font-size: 22px; + width: 22px; + height: 22px; + } + + &--blue { + background: #dbeafe; + color: #1d4ed8; + } + + &--cyan { + background: #cffafe; + color: #0891b2; + } + + &--violet { + background: #ede9fe; + color: #6d28d9; + } + + &--amber { + background: #fef3c7; + color: #b45309; + } + } + + &__title { + margin: 6px 0 0; + font-size: 15px; + font-weight: 700; + color: #0f172a; + } + + &__desc { + margin: 0; + font-size: 12.5px; + color: #64748b; + line-height: 1.5; + flex: 1; + } + + &__cta { + display: inline-flex; + align-items: center; + gap: 4px; + margin-top: 8px; + font-size: 13px; + font-weight: 700; + color: #1d4ed8; + + mat-icon { + font-size: 16px; + width: 16px; + height: 16px; + } + + &--locked { + color: #94a3b8; + + mat-icon { + color: #94a3b8; + } + } + } + + &__type { + position: absolute; + top: 14px; + right: 50px; + pointer-events: none; + visibility: hidden; + } +} + +/* ───────────────────────── Back button (pill) ───────────────────────── */ +.ac-back { + display: inline-flex; + align-items: center; + gap: 12px; + padding: 6px 16px 6px 6px; + margin-bottom: 16px; + border: 1px solid #e2e8f0; + border-radius: 999px; + background: #fff; + cursor: pointer; + transition: + border-color 0.18s, + box-shadow 0.18s, + transform 0.18s, + background 0.18s; + + &:hover { + border-color: #93c5fd; + background: #eff6ff; + box-shadow: 0 4px 14px rgb(37 99 235 / 12%); + transform: translateX(-2px); + + .ac-back__icon { + background: #1e40af; + color: #fff; + + mat-icon { + transform: translateX(-1px); + } + } + } + + &:focus-visible { + outline: 2px solid #1d4ed8; + outline-offset: 2px; + } + + &__icon { + width: 32px; + height: 32px; + border-radius: 999px; + background: #eff6ff; + color: #1e40af; + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + transition: + background 0.18s, + color 0.18s; + + mat-icon { + font-size: 18px; + width: 18px; + height: 18px; + transition: transform 0.18s; + } + } + + &__text { + display: flex; + flex-direction: column; + line-height: 1.15; + text-align: left; + } + + &__sub { + font-size: 10px; + font-weight: 700; + letter-spacing: 0.5px; + text-transform: uppercase; + color: #94a3b8; + } + + &__main { + font-size: 13px; + font-weight: 700; + color: #0f172a; + } + + &--inner { + margin-bottom: 14px; + background: #f8fafc; + + .ac-back__icon { + background: #fff; + color: #475569; + } + } +} + +/* ───────────────────────── Picker CTA bar ───────────────────────── */ +.ac-picker-cta { + margin-top: 18px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; + padding: 14px 18px; + border-radius: 12px; + background: #f8fafc; + border: 1px solid #e2e8f0; + + &__hint { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + color: #475569; + line-height: 1.5; + + mat-icon { + font-size: 18px; + width: 18px; + height: 18px; + color: #94a3b8; + } + + strong { + color: #166534; + } + } + + &__btn { + height: 42px !important; + padding: 0 22px !important; + font-weight: 700 !important; + white-space: nowrap; + + mat-icon { + font-size: 18px; + width: 18px; + height: 18px; + margin-left: 6px; + vertical-align: middle; + } + } + + @media (width <= 600px) { + flex-direction: column; + align-items: stretch; + + &__btn { + width: 100%; + } + } +} + +/* ───────────────────────── Setup panel ───────────────────────── */ +.ac-panel { + margin-top: 14px; + border: 1px solid #e2e8f0; + border-radius: 16px; + background: #fff; + padding: 16px 20px; + scroll-margin-top: 80px; + + &__head { + display: flex; + align-items: center; + gap: 14px; + padding-bottom: 12px; + border-bottom: 1px solid #f1f5f9; + margin-bottom: 16px; + } + + &__icon { + width: 50px; + height: 50px; + border-radius: 14px; + display: inline-flex; + align-items: center; + justify-content: center; + + mat-icon { + font-size: 24px; + width: 24px; + height: 24px; + } + } + + &__title { + flex: 1; + min-width: 0; + + h3 { + margin: 0 0 3px; + font-size: 16px; + font-weight: 700; + color: #0f172a; + } + } + + &__sub { + margin: 0 0 16px; + font-size: 12.5px; + color: #64748b; + line-height: 1.5; + } + + &__body { + display: flex; + flex-direction: column; + gap: 14px; + } +} + +/* ───────────────────────── Progress stepper ───────────────────────── */ +.ac-stepper { + list-style: none; + margin: 0 0 14px; + padding: 10px 16px; + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 8px; + background: #f8fafc; + border: 1px solid #e2e8f0; + border-radius: 12px; + + li { + position: relative; + display: flex; + align-items: center; + gap: 10px; + padding: 6px 4px; + + &::after { + content: ''; + position: absolute; + top: 50%; + right: -14px; + width: 16px; + height: 2px; + background: #cbd5e1; + border-radius: 1px; + } + + &:last-child::after { + display: none; + } + } + + &__num { + width: 28px; + height: 28px; + border-radius: 999px; + background: #e2e8f0; + color: #94a3b8; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 12px; + font-weight: 700; + flex-shrink: 0; + transition: + background 0.18s, + color 0.18s, + box-shadow 0.18s; + } + + &__text { + display: flex; + flex-direction: column; + line-height: 1.25; + min-width: 0; + + strong { + font-size: 12.5px; + font-weight: 700; + color: #94a3b8; + } + + em { + font-style: normal; + font-size: 11px; + color: #94a3b8; + } + } + + li.is-active &__num { + background: #1e40af; + color: #fff; + box-shadow: 0 0 0 4px #dbeafe; + } + + li.is-active &__text strong { + color: #1e40af; + } + + li.is-active &__text em { + color: #475569; + } + + li.is-done &__num { + background: #16a34a; + color: #fff; + } + + li.is-done &__text strong { + color: #166534; + } + + li.is-done &__text em { + color: #15803d; + } + + li.is-done::after { + background: #86efac; + } + + @media (width <= 720px) { + grid-template-columns: 1fr; + + li::after { + display: none; + } + } +} + +/* ───────────────────────── OTP step blocks ───────────────────────── */ +.ac-otp-layout { + display: grid; + grid-template-columns: 1.4fr 1fr; + gap: 22px; + + @media (width <= 860px) { + grid-template-columns: 1fr; + } +} + +.ac-otp-form { + display: flex; + flex-direction: column; + gap: 12px; + + &__title { + margin: 0; + font-size: 13px; + font-weight: 700; + color: #1e40af; + letter-spacing: 0.3px; + text-transform: uppercase; + } + + &__hint { + margin: 0; + font-size: 12.5px; + color: #64748b; + line-height: 1.55; + } +} + +/* Indeterminate progress bar shown while OTP is sending / verifying */ +.ac-progress { + height: 4px !important; + border-radius: 999px !important; + margin: -4px 0 16px; + + ::ng-deep .mdc-linear-progress__bar-inner { + border-color: #1d4ed8 !important; + } + + ::ng-deep .mdc-linear-progress__buffer-bar { + background: #dbeafe !important; + } +} + +/* Inline error block (fully visible, unlike mat-error which only renders inside form-field) */ +.ac-inline-error { + display: flex; + align-items: center; + gap: 8px; + margin-top: 6px; + padding: 8px 12px; + border-radius: 10px; + background: #fef2f2; + border: 1px solid #fecaca; + color: #b91c1c; + font-size: 12.5px; + line-height: 1.4; + + mat-icon { + font-size: 18px; + width: 18px; + height: 18px; + color: #dc2626; + flex-shrink: 0; + } +} + +.ac-prefill-chip { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + margin-top: 2px; + border-radius: 999px; + background: #eff6ff; + border: 1px solid #bfdbfe; + color: #1e40af; + font-size: 12px; + font-weight: 600; + width: fit-content; + + mat-icon { + font-size: 16px; + width: 16px; + height: 16px; + color: #1e40af; + } +} + +.ac-input-row { + display: grid; + grid-template-columns: 1fr auto; + align-items: start; + gap: 12px; + + @media (width <= 540px) { + grid-template-columns: 1fr; + } + + &__btn { + height: 56px !important; + padding: 0 22px !important; + font-weight: 700 !important; + white-space: nowrap; + + mat-icon { + font-size: 18px; + width: 18px; + height: 18px; + margin-right: 6px; + vertical-align: middle; + } + } +} + +.ac-aside { + border: 1px solid #e2e8f0; + border-radius: 12px; + background: linear-gradient(180deg, #f8fafc 0%, #fff 60%); + padding: 14px 16px; + align-self: start; + + &__head { + display: flex; + align-items: center; + gap: 6px; + font-size: 12.5px; + font-weight: 700; + color: #0f172a; + margin-bottom: 10px; + + mat-icon { + color: #1e40af; + font-size: 18px; + width: 18px; + height: 18px; + } + } + + ul { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 8px; + } + + li { + display: flex; + align-items: center; + gap: 8px; + font-size: 12.5px; + color: #475569; + line-height: 1.5; + + mat-icon { + font-size: 16px; + width: 16px; + height: 16px; + color: #64748b; + flex-shrink: 0; + } + } +} + +.ac-otp-target { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 12px; + background: #f1f5f9; + border: 1px solid #e2e8f0; + border-radius: 10px; + font-size: 12.5px; + color: #475569; + + mat-icon { + font-size: 16px; + width: 16px; + height: 16px; + color: #64748b; + } + + strong { + color: #0f172a; + } + + &__edit { + margin-left: auto !important; + min-width: auto !important; + padding: 0 6px !important; + font-size: 12px !important; + } +} + +.ac-otp-done { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 14px; + background: #f0fdf4; + border: 1px solid #bbf7d0; + border-radius: 10px; + font-size: 13px; + color: #166534; + max-width: 720px; + + > mat-icon { + color: #16a34a; + font-size: 22px; + width: 22px; + height: 22px; + flex-shrink: 0; + } + + > div { + flex: 1; + display: flex; + flex-direction: column; + min-width: 0; + + strong { + font-weight: 700; + color: #0f172a; + } + + span { + color: #475569; + font-size: 12px; + } + + code { + background: #fff; + border: 1px solid #d1fae5; + padding: 1px 6px; + border-radius: 4px; + font-family: 'SF Mono', 'Cascadia Code', Consolas, monospace; + font-size: 11.5px; + } + } +} + +.ac-ff { + width: 100%; +} + +/* Subtle read-only treatment — slate background + lock suffix */ +.ac-ff--readonly { + ::ng-deep .mat-mdc-text-field-wrapper { + background: #f1f5f9; + } + + ::ng-deep .mat-mdc-form-field-flex { + cursor: default; + } + + /* stylelint-disable-next-line selector-no-qualifying-type */ + input[readonly] { + cursor: default; + color: #0f172a; + font-weight: 600; + } +} + +.ac-readonly-lock { + font-size: 18px; + width: 18px; + height: 18px; + color: #94a3b8; + margin-right: 4px; +} + +.ac-ff--otp input { + letter-spacing: 6px; + font-family: 'SF Mono', 'Cascadia Code', Consolas, monospace; + font-size: 16px; + font-weight: 700; +} + +.ac-prefix { + margin-right: 4px; + color: #475569; + font-weight: 600; + + mat-icon { + font-size: 18px; + width: 18px; + height: 18px; + color: #64748b; + } +} + +.ac-link { + cursor: pointer; + color: #1d4ed8; + font-weight: 600; + text-decoration: underline; + text-underline-offset: 2px; +} + +/* ───────────────────────── TOTP setup ───────────────────────── */ +.ac-totp-empty { + display: flex; + align-items: center; + gap: 14px; + padding: 18px; + border: 1px dashed #cbd5e1; + border-radius: 12px; + background: #f8fafc; + + > mat-icon { + font-size: 40px; + width: 40px; + height: 40px; + color: #94a3b8; + flex-shrink: 0; + } + + > div { + flex: 1; + min-width: 0; + + strong { + display: block; + font-size: 14px; + color: #0f172a; + font-weight: 700; + margin-bottom: 4px; + } + + p { + margin: 0; + font-size: 12.5px; + color: #64748b; + line-height: 1.5; + } + } +} + +.ac-totp-setup { + display: grid; + grid-template-columns: 220px 1fr; + gap: 24px; + + @media (width <= 720px) { + grid-template-columns: 1fr; + } + + &__qr { + text-align: center; + } + + &__details { + display: flex; + flex-direction: column; + gap: 14px; + } +} + +.ac-qr-frame { + display: inline-block; + padding: 14px; + border: 1px solid #cbd5e1; + border-radius: 14px; + background: #fff; +} + +.ac-qr-grid { + width: 168px; + height: 168px; + display: flex; + flex-direction: column; + gap: 1px; +} + +.ac-qr-row { + flex: 1; + display: flex; + gap: 1px; +} + +.ac-qr-cell { + flex: 1; + background: #e2e8f0; + border-radius: 1px; + + &.is-on { + background: #0f172a; + } +} + +.ac-qr-caption { + margin: 10px 0 0; + font-size: 12px; + color: #64748b; +} + +.ac-totp-step-list { + display: flex; + flex-direction: column; + gap: 10px; +} + +.ac-totp-step { + display: flex; + gap: 10px; + font-size: 13px; + color: #475569; + line-height: 1.55; + + &__num { + width: 22px; + height: 22px; + border-radius: 999px; + background: #1e40af; + color: #fff; + display: inline-flex; + align-items: center; + justify-content: center; + font-weight: 700; + font-size: 11.5px; + flex-shrink: 0; + margin-top: 1px; + } + + strong { + color: #0f172a; + } +} + +.ac-totp-secret { + margin-top: 6px; + display: inline-flex; + align-items: center; + gap: 4px; + padding: 6px 6px 6px 12px; + border: 1px dashed #cbd5e1; + border-radius: 8px; + background: #f8fafc; + + code { + font-family: 'SF Mono', 'Cascadia Code', Consolas, monospace; + font-size: 13px; + font-weight: 700; + color: #0f172a; + letter-spacing: 1px; + } +} + +.ac-totp-actions { + display: flex; + gap: 10px; + flex-wrap: wrap; + + button mat-icon { + font-size: 18px; + width: 18px; + height: 18px; + margin-right: 6px; + vertical-align: middle; + } +} + +/* ───────────────────────── Hardware Token (sub-list) ───────────────────────── */ +.ac-hw-list { + display: flex; + flex-direction: column; + gap: 10px; +} + +.ac-hw-row { + display: grid; + grid-template-columns: 56px 1fr auto; + align-items: center; + gap: 14px; + padding: 14px 16px; + border: 1px solid #e2e8f0; + border-radius: 14px; + background: #fff; + transition: + border-color 0.15s, + box-shadow 0.15s; + + &:hover { + border-color: #93c5fd; + box-shadow: 0 4px 14px rgb(37 99 235 / 8%); + } + + &.is-registered { + border-color: #bbf7d0; + background: linear-gradient(180deg, #f0fdf4 0%, #fff 60%); + } + + &__icon { + width: 56px; + height: 56px; + border-radius: 14px; + background: #fef3c7; + color: #b45309; + display: inline-flex; + align-items: center; + justify-content: center; + + mat-icon { + font-size: 28px; + width: 28px; + height: 28px; + } + } + + &.is-registered &__icon { + background: #dcfce7; + color: #166534; + } + + &__main { + min-width: 0; + } + + &__title { + display: flex; + align-items: center; + gap: 10px; + font-size: 14px; + font-weight: 700; + color: #0f172a; + margin-bottom: 3px; + } + + &__desc { + margin: 0; + font-size: 12.5px; + color: #64748b; + line-height: 1.5; + } + + &__meta { + margin-top: 6px; + display: flex; + flex-wrap: wrap; + gap: 12px; + font-size: 12px; + color: #475569; + + span { + display: inline-flex; + align-items: center; + gap: 4px; + + mat-icon { + font-size: 14px; + width: 14px; + height: 14px; + color: #64748b; + } + + code { + font-family: 'SF Mono', 'Cascadia Code', Consolas, monospace; + background: #f1f5f9; + border: 1px solid #e2e8f0; + padding: 1px 6px; + border-radius: 4px; + } + } + } + + &__cta button mat-icon { + font-size: 18px; + width: 18px; + height: 18px; + margin-right: 6px; + vertical-align: middle; + } +} + +/* ───────────────────────── Hardware Token (registration stage) ───────────────────────── */ +.ac-hw-stage { + display: grid; + grid-template-columns: 240px 1fr; + gap: 24px; + padding: 22px; + border-radius: 16px; + background: linear-gradient(135deg, #f8fafc 0%, #fff 70%); + border: 1px solid #e2e8f0; + + @media (width <= 720px) { + grid-template-columns: 1fr; + text-align: center; + } + + &.is-busy { + border-color: #93c5fd; + background: linear-gradient(135deg, #eff6ff 0%, #fff 70%); + } + + &.is-done { + border-color: #bbf7d0; + background: linear-gradient(135deg, #f0fdf4 0%, #fff 70%); + } +} + +.ac-hw-device { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + gap: 14px; + + &__icon { + position: relative; + width: 140px; + height: 140px; + border-radius: 22px; + background: #fef3c7; + color: #b45309; + display: inline-flex; + align-items: center; + justify-content: center; + box-shadow: inset 0 1px 0 rgb(255 255 255 / 60%); + + mat-icon { + font-size: 64px; + width: 64px; + height: 64px; + } + } + + &__pulse { + position: absolute; + inset: -6px; + border-radius: 26px; + border: 3px solid #93c5fd; + animation: ac-pulse 1.4s infinite; + } + + &__check { + position: absolute; + bottom: -6px; + right: -6px; + width: 36px; + height: 36px; + border-radius: 999px; + background: #16a34a; + color: #fff; + display: inline-flex; + align-items: center; + justify-content: center; + box-shadow: 0 2px 8px rgb(22 163 74 / 35%); + + mat-icon { + font-size: 22px; + width: 22px; + height: 22px; + } + } + + &__connectors { + display: flex; + gap: 6px; + + span { + width: 26px; + height: 4px; + border-radius: 2px; + background: #cbd5e1; + } + } +} + +.ac-hw-stage.is-done .ac-hw-device__icon { + background: #dcfce7; + color: #166534; +} + +.ac-hw-stage.is-busy .ac-hw-device__icon { + background: #dbeafe; + color: #1d4ed8; +} + +@keyframes ac-pulse { + 0% { + transform: scale(0.95); + opacity: 0.6; + } + + 60% { + transform: scale(1.04); + opacity: 0; + } + + 100% { + transform: scale(1.04); + opacity: 0; + } +} + +.ac-hw-info { + display: flex; + flex-direction: column; + gap: 12px; + min-width: 0; + + h4 { + display: flex; + align-items: center; + gap: 10px; + margin: 0; + font-size: 16px; + font-weight: 700; + color: #0f172a; + } + + p { + margin: 0; + font-size: 12.5px; + color: #64748b; + line-height: 1.5; + } + + &__detail { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px 14px; + padding: 10px 12px; + background: #f0fdf4; + border: 1px solid #bbf7d0; + border-radius: 10px; + font-size: 12px; + color: #166534; + + code { + font-family: 'SF Mono', 'Cascadia Code', Consolas, monospace; + font-weight: 700; + color: #0f172a; + background: #fff; + padding: 1px 6px; + border-radius: 4px; + border: 1px solid #d1fae5; + } + } + + &__label { + display: block; + font-size: 10.5px; + font-weight: 700; + color: #475569; + text-transform: uppercase; + letter-spacing: 0.4px; + margin-bottom: 2px; + } + + &__actions { + display: flex; + gap: 10px; + flex-wrap: wrap; + margin-top: 6px; + + button mat-icon { + font-size: 18px; + width: 18px; + height: 18px; + margin-right: 6px; + vertical-align: middle; + + &.mat-icon { + animation: none; + } + } + } +} + +.ac-hw-steps { + list-style: none; + padding: 0; + margin: 4px 0 0; + display: flex; + flex-direction: column; + gap: 6px; + + li { + display: flex; + align-items: center; + gap: 8px; + font-size: 12.5px; + color: #94a3b8; + line-height: 1.5; + + mat-icon { + font-size: 18px; + width: 18px; + height: 18px; + color: #cbd5e1; + } + + strong { + color: inherit; + } + + &.is-active { + color: #1d4ed8; + + mat-icon { + color: #1d4ed8; + animation: ac-spin 1s linear infinite; + } + } + + &.is-done { + color: #166534; + + mat-icon { + color: #16a34a; + } + + strong { + color: #0f172a; + } + } + } +} + +@keyframes ac-spin { + to { + transform: rotate(360deg); + } +} + +/* ───────────────────────── Mobile tweaks ───────────────────────── */ +@media (width <= 600px) { + .ac-page-head { + flex-direction: column; + align-items: flex-start; + } + + .ac-hw-row { + grid-template-columns: 56px 1fr; + grid-template-rows: auto auto; + } + + .ac-hw-row__cta { + grid-column: 1 / -1; + } +} diff --git a/src/app/access-control/access-control.component.ts b/src/app/access-control/access-control.component.ts new file mode 100644 index 000000000..16ebb78ee --- /dev/null +++ b/src/app/access-control/access-control.component.ts @@ -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; +} + +@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 */ + } + } +} diff --git a/src/app/access-control/access-control.module.ts b/src/app/access-control/access-control.module.ts new file mode 100644 index 000000000..5a2ae8545 --- /dev/null +++ b/src/app/access-control/access-control.module.ts @@ -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 {} diff --git a/src/app/access-control/access-control.service.ts b/src/app/access-control/access-control.service.ts new file mode 100644 index 000000000..2363964c8 --- /dev/null +++ b/src/app/access-control/access-control.service.ts @@ -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 { + return this.http.post('/login/otp/generate', { username }); + } + + verifyMobileOtp(otpId: number, otp: string): Observable { + return this.http.post('/login/otp/verify', { otpId, otp }); + } + + // ───── Email OTP (SMTP) ───── + sendEmailOtp(username: string): Observable { + return this.http.post('/login/otp/email/generate', { username }); + } + + verifyEmailOtp(otpId: number, otp: string): Observable { + return this.http.post('/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 { + return this.http.get('/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 { + return this.http.post('/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 + }); + } +} diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 1a72a4c8f..dee8c359d 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -46,6 +46,8 @@ import { SearchModule } from './search/search.module'; import { NotificationsModule } from './notifications/notifications.module'; import { CollectionsModule } from './collections/collections.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 { ConfigurationWizardModule } from './configuration-wizard/configuration-wizard.module'; import { RemittancesModule } from './remittances/remittances.module'; @@ -126,6 +128,8 @@ export function HttpLoaderFactory(http: HttpClient) { HomeModule, LoginModule, ProfileModule, + AccessControlModule, + OtpAuditLogsModule, SettingsModule, NavigationModule, ClientsModule, diff --git a/src/app/clients/client-stepper/client-family-members-step/client-family-member-dialog/client-family-member-dialog.component.ts b/src/app/clients/client-stepper/client-family-members-step/client-family-member-dialog/client-family-member-dialog.component.ts index bc326ad04..06dee441f 100644 --- a/src/app/clients/client-stepper/client-family-members-step/client-family-member-dialog/client-family-member-dialog.component.ts +++ b/src/app/clients/client-stepper/client-family-members-step/client-family-member-dialog/client-family-member-dialog.component.ts @@ -216,7 +216,7 @@ export class ClientFamilyMemberDialogComponent implements OnInit { } this.searching = true; this.searchTimer = setTimeout(() => { - this.clientsService.searchByText(text, 0, 8).subscribe({ + this.clientsService.searchByText(text, 0, 8, '', '', true).subscribe({ next: (data: any) => { this.searchResults = data?.content || []; this.searching = false; diff --git a/src/app/clients/client-stepper/client-family-members-step/client-family-members-step.component.ts b/src/app/clients/client-stepper/client-family-members-step/client-family-members-step.component.ts index 07132b599..ec60d8867 100644 --- a/src/app/clients/client-stepper/client-family-members-step/client-family-members-step.component.ts +++ b/src/app/clients/client-stepper/client-family-members-step/client-family-members-step.component.ts @@ -161,8 +161,13 @@ export class ClientFamilyMembersStepComponent implements OnInit { panelClass: 'fm-dialog-panel' }); ref.afterClosed().subscribe((response: any) => { - if (response?.member) { - this.clientFamilyMembers.splice(index, 1, response.member); + if (!response?.member) return; + 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}` } }); 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); } }); @@ -228,7 +241,7 @@ export class ClientFamilyMembersStepComponent implements OnInit { } this.nomineeSearching = true; this.nomineeSearchTimer = setTimeout(() => { - this.clientsService.searchByText(text, 0, 10).subscribe({ + this.clientsService.searchByText(text, 0, 10, '', '', true).subscribe({ next: (data: any) => { this.nomineeSearchResults = data?.content || []; this.nomineeSearching = false; @@ -324,7 +337,7 @@ export class ClientFamilyMembersStepComponent implements OnInit { } this.guardianSearching = true; this.guardianSearchTimer = setTimeout(() => { - this.clientsService.searchByText(text, 0, 10).subscribe({ + this.clientsService.searchByText(text, 0, 10, '', '', true).subscribe({ next: (data: any) => { this.guardianSearchResults = data?.content || []; this.guardianSearching = false; diff --git a/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts b/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts index c19dddb10..bfda03ca4 100644 --- a/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts +++ b/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts @@ -27,7 +27,7 @@ import { UntypedFormControl, ReactiveFormsModule } from '@angular/forms'; -import { Subject } from 'rxjs'; +import { Subject, Subscription, merge } from 'rxjs'; import { filter, switchMap, takeUntil } from 'rxjs/operators'; import { ClientsService } from 'app/clients/clients.service'; import { Dates } from 'app/core/utils/dates'; @@ -340,6 +340,8 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges sanctionsBlocked = false; /** Screening log ID from sanctions API */ 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 @@ -500,6 +502,7 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges } if (data.nameVerified === true || data.nameVerified === 'true' || data.nameVerified === 1) { this.sanctionsVerified = true; + this.verifiedName = this.currentNameSnapshot(); } else if (data.nameVerified === false || data.nameVerified === 'false' || data.nameVerified === 0) { 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 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. */ verifySanctions() { - 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 || ''; - - const name = - fullname || - [ - firstname, - middlename, - lastname - ] - .filter(Boolean) - .join(' '); + const name = this.currentNameSnapshot(); const dialogRef = this.dialog.open(SanctionsVerifyDialogComponent, { width: '550px', @@ -927,14 +919,62 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges // CLEAR — no match found this.sanctionsVerified = true; this.sanctionsBlocked = false; + this.verifiedName = name; } else if (result.verified === false) { // FLAGGED — match found, block account opening this.sanctionsVerified = false; this.sanctionsBlocked = true; + this.verifiedName = ''; } }); } + /** Concatenated current name from the form (handles individual + entity) */ + private currentNameSnapshot(): string { + const firstname = this.createClientForm.get('firstname')?.value || ''; + const middlename = this.createClientForm.get('middlename')?.value || ''; + const lastname = this.createClientForm.get('lastname')?.value || ''; + const fullname = this.createClientForm.get('fullname')?.value || ''; + return ( + fullname || + [ + firstname, + middlename, + lastname + ] + .filter(Boolean) + .join(' ') + ).trim(); + } + + /** + * Watch name fields — once a name has been verified, any edit invalidates the + * sanctions verification badge so the user must re-verify before submitting. + */ + private nameWatchSub: Subscription | null = null; + private watchNameChangesForReverification() { + this.nameWatchSub?.unsubscribe(); + const fields = [ + 'firstname', + 'middlename', + 'lastname', + 'fullname' + ]; + const controls = fields.map((f) => this.createClientForm.get(f)).filter((c) => !!c); + if (!controls.length) return; + this.nameWatchSub = merge(...controls.map((c) => c!.valueChanges)) + .pipe(takeUntil(this.destroy$)) + .subscribe(() => { + // Only invalidate if a verification was previously recorded + if (!this.sanctionsVerified && !this.sanctionsBlocked) return; + if (this.currentNameSnapshot() !== this.verifiedName) { + this.sanctionsVerified = false; + this.sanctionsBlocked = false; + this.screeningLogId = null; + } + }); + } + /** Search existing clients to select as guardian */ onGuardianSearch(text: string): void { this.guardianSearchText = text; @@ -947,7 +987,7 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges this.guardianSearching = true; this.guardianSearchTimer = setTimeout(() => { - this.clientService.searchByText(text, 0, 10).subscribe({ + this.clientService.searchByText(text, 0, 10, '', '', true).subscribe({ next: (data: any) => { this.guardianSearchResults = data?.content || []; this.guardianSearching = false; @@ -1106,7 +1146,7 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges } this.signatorySearching = true; this.signatorySearchTimer = setTimeout(() => { - this.clientService.searchByText(text, 0, 10).subscribe({ + this.clientService.searchByText(text, 0, 10, '', '', true).subscribe({ next: (data: any) => { this.signatorySearchResults = data?.content || []; 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 dateFormat = this.settingsService.dateFormat; const locale = this.settingsService.language.code; + const isEditMode = !!this.clientData; 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]; } } diff --git a/src/app/clients/client-stepper/client-india-address-step/client-india-address-step.component.html b/src/app/clients/client-stepper/client-india-address-step/client-india-address-step.component.html index 879172463..c36618643 100644 --- a/src/app/clients/client-stepper/client-india-address-step/client-india-address-step.component.html +++ b/src/app/clients/client-stepper/client-india-address-step/client-india-address-step.component.html @@ -114,22 +114,165 @@
Proof of Address
-
- - Address Proof Type - verified_user - - @for (pt of proofTypes; track pt) { - {{ pt }} + @if (loadingProofs) { +
+ + Loading document types… +
+ } + +
+ +
+ @for (group of addressGroups; track group.label; let last = $last) { +
folder_open {{ group.label }}
+
+ @for (p of group.items; track p.id) { + + + {{ isAddrProofSelected(p) ? 'check_circle' : 'radio_button_unchecked' }} + + {{ p.name }} + + } +
+ @if (!last) { +
} - - - - Document Number - tag - - + } +
+ + @if (selectedAddrProof?.info) { +
+ check_circle + {{ selectedAddrProof.info }} +
+ } + + @if (selectedAddrProof) { +
+ + {{ selectedAddrProof.documentKeyLabel || 'Document number' }} + tag + + + @if (selectedAddrProof.requiresDate) { + + Issue / bill date + event + + + } +
+ +
+
+
+ + image + {{ selectedAddrProof.sides === 2 ? 'Front side' : selectedAddrProof.name + ' (single copy)' }} + + Required +
+ +
+ + @if (selectedAddrProof.sides === 2) { +
+
+ image Back side + Required +
+ +
+ } +
+ + @if (selectedAddrProof.sides === 2 && addrMergedPreview) { +
+
+ + filter_none + Combined preview (front + back) + + Saved as a single PNG. Re-crop a side to update the merged file. +
+
+ Merged address proof +
+
+ + +
+
+ } + }
diff --git a/src/app/clients/client-stepper/client-india-address-step/client-india-address-step.component.scss b/src/app/clients/client-stepper/client-india-address-step/client-india-address-step.component.scss index b4638207c..6cd3e0c1d 100644 --- a/src/app/clients/client-stepper/client-india-address-step/client-india-address-step.component.scss +++ b/src/app/clients/client-stepper/client-india-address-step/client-india-address-step.component.scss @@ -217,3 +217,346 @@ $muted: #64748b; 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; + } +} diff --git a/src/app/clients/client-stepper/client-india-address-step/client-india-address-step.component.ts b/src/app/clients/client-stepper/client-india-address-step/client-india-address-step.component.ts index 935ac2b63..0f063d2a5 100644 --- a/src/app/clients/client-stepper/client-india-address-step/client-india-address-step.component.ts +++ b/src/app/clients/client-stepper/client-india-address-step/client-india-address-step.component.ts @@ -14,7 +14,10 @@ import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { MatCheckbox } from '@angular/material/checkbox'; import { MatTabsModule } from '@angular/material/tabs'; import { MatIcon } from '@angular/material/icon'; +import { MatIconButton, MatButton } from '@angular/material/button'; import { ClientsService } from '../../clients.service'; +import { ProofType, normalizeProofType, groupProofsByCategory } from '../../shared/kyc-proof-types'; +import { KycUploadHelperService } from '../../shared/kyc-upload-helper.service'; @Component({ selector: 'mifosx-client-india-address-step', @@ -25,13 +28,16 @@ import { ClientsService } from '../../clients.service'; FaIconComponent, MatCheckbox, MatTabsModule, - MatIcon + MatIcon, + MatIconButton, + MatButton ] }) export class ClientIndiaAddressStepComponent implements OnInit, OnChanges { private fb = inject(UntypedFormBuilder); private http = inject(HttpClient); private clientsService = inject(ClientsService); + private kycUpload = inject(KycUploadHelperService); /** Client ID — when set, loads existing addresses for edit mode */ @Input() clientId: string = ''; @@ -66,11 +72,12 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges { ], country: [{ value: 'India', disabled: true }], landmark: [''], - proofType: [ + proofId: [ '', Validators.required ], - proofDocNumber: [''] + documentKey: [''], + issueDate: [''] }); currentForm: UntypedFormGroup = this.fb.group({ @@ -166,14 +173,15 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges { correspondence: [] }; - proofTypes = [ - 'Aadhaar Card', - 'Passport', - 'Voter ID', - 'Driving Licence', - 'Utility Bill (not older than 3 months)', - 'Bank Statement' - ]; + /** KYC address-proof picker state (mirrors identities-tab dialog) */ + addressProofs: ProofType[] = []; + addressGroups: { label: string; items: ProofType[] }[] = []; + selectedAddrProof: ProofType | null = null; + loadingProofs = false; + addrFiles: { front: File | null; back: File | null } = { front: null, back: null }; + /** Combined front+back PNG (built automatically once both image sides are ready) */ + addrMergedFile: File | null = null; + addrMergedPreview: string | null = null; residenceTypes = [ 'Owned', @@ -206,11 +214,116 @@ export class ClientIndiaAddressStepComponent implements OnInit, OnChanges { private typeToTab: Record = {}; ngOnInit(): void { + this.loadProofTypes(); if (this.clientId) { 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 { + 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 { + 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 { + 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 { if (changes['readOnly']) { const forms = [ diff --git a/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.html b/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.html index 429978f87..a7efccd1c 100644 --- a/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.html +++ b/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.html @@ -640,33 +640,163 @@ } + +
+
+ badge +
+

Identity proof

+

Select document type — upload requirements will update automatically

+
+
+ +
+ +
+ @for (group of kycDocGroups; track group.label; let last = $last) { +
folder_open {{ group.label }}
+
+ @for (p of group.items; track p.id) { + + + {{ isKycDocSelected(p) ? 'check_circle' : 'radio_button_unchecked' }} + + {{ p.name }} + + } +
+ @if (!last) { +
+ } + } +
+ @if (kycForm.get('kycDocumentName')?.hasError('required') && kycForm.get('kycDocumentName')?.touched) { +
KYC Document Type is required
+ } +
+ + @if (selectedKycDoc?.info) { +
+ check_circle + {{ selectedKycDoc.info }} +
+ } + + @if (selectedKycDoc) { +
+
+
+ + image + {{ selectedKycDoc.sides === 2 ? 'Front side' : selectedKycDoc.name + ' (single copy)' }} + + Required +
+ +
+ + @if (selectedKycDoc.sides === 2) { +
+
+ image Back side + Required +
+ +
+ } +
+ + @if (selectedKycDoc.sides === 2 && kycDocMergedPreview) { +
+
+ + filter_none + Combined preview (front + back) + + Saved as a single PNG. Re-crop a side to update the merged file. +
+
+ Merged KYC document +
+
+ + +
+
+ } + } +
+
- KYC Document Type - description - - @for (doc of kycDocumentTypes; track doc) { - - {{ doc.name }} - @if (doc.mandatory) { - * - } - - } - - @if (kycForm.get('kycDocumentName')?.hasError('required') && kycForm.get('kycDocumentName')?.touched) { - KYC Document Type is required - } - - - - Document Number + {{ selectedKycDoc?.documentKeyLabel || 'Document Number' }} tag Photograph & Signature + Required
-
+
@if (photographPreview) {
Photo @@ -1023,15 +1158,25 @@
} @else {
- Client Photograph + + Client Photograph + * + JPG / PNG, max 2MB + @if (photoSigTouched) { + Photograph is required + } }
-
+
@if (signaturePreview) {
Signature @@ -1047,12 +1192,18 @@
} @else {
- Specimen Signature + + Specimen Signature + * + Scanned image, max 1MB + @if (photoSigTouched) { + Signature is required + } }
diff --git a/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.scss b/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.scss index 058940306..cc38a02a0 100644 --- a/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.scss +++ b/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.scss @@ -23,6 +23,19 @@ color: var(--md-sys-color-primary, #1074b9); 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 { @@ -948,12 +961,23 @@ border-radius: 10px; background: #f9fafb; text-align: center; - transition: border-color 0.15s; + transition: + border-color 0.15s, + background 0.15s; &:hover { border-color: #1353a0; } + &--missing { + border-color: #dc2626; + background: #fef2f2; + + &:hover { + border-color: #b91c1c; + } + } + &__icon { width: 44px; height: 44px; @@ -972,6 +996,19 @@ color: #0f172a; } + &__req { + color: #dc2626; + font-weight: 700; + margin-left: 2px; + } + + &__error { + margin-top: 6px; + font-size: 12px; + font-weight: 600; + color: #b91c1c; + } + &__hint { font-size: 11px; color: #94a3b8; @@ -1214,3 +1251,393 @@ 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; + } +} diff --git a/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.ts b/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.ts index 288384984..379f15e6f 100644 --- a/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.ts +++ b/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.ts @@ -8,19 +8,28 @@ import { Component, Input, OnInit, OnChanges, OnDestroy, SimpleChanges, inject } from '@angular/core'; 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 { MatProgressSpinner } from '@angular/material/progress-spinner'; import { MatSelectTrigger } from '@angular/material/select'; import { MatDivider } from '@angular/material/divider'; import { MatIcon } from '@angular/material/icon'; +import { MatIconButton, MatButton } from '@angular/material/button'; import { MatCheckbox } from '@angular/material/checkbox'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { ClientsService } from '../../clients.service'; import { SettingsService } from 'app/settings/settings.service'; 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({ selector: 'mifosx-client-kyc-step', @@ -31,6 +40,8 @@ import { Dates } from 'app/core/utils/dates'; FormsModule, MatDivider, MatIcon, + MatIconButton, + MatButton, MatCheckbox, MatProgressSpinner, FaIconComponent, @@ -43,6 +54,7 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy { private clientsService = inject(ClientsService); private settingsService = inject(SettingsService); private dateUtils = inject(Dates); + private kycUpload = inject(KycUploadHelperService); /** Set by parent after client is created */ @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 */ 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 */ private defaultDocumentTypes = [ - 'Aadhaar Card', - 'Passport', - 'PAN Card', - 'Driving Licence', - 'Voter ID', - 'NREGA Job Card', - 'National Population Register Letter' + { name: 'Aadhaar Card', code: 'AADHAAR' }, + { name: 'Passport', code: 'PASSPORT' }, + { name: 'PAN Card', code: 'PAN' }, + { name: 'Driving Licence', code: 'DRIVING_LICENSE' }, + { name: 'Voter ID', code: 'VOTER_ID' }, + { name: 'NREGA Job Card', code: 'NREGA_JOB_CARD' }, + { name: 'National Population Register Letter', code: 'NPR_LETTER' } ]; /** Constitution-based KYC document types for non-individual clients */ private constitutionDocTypes: Record = { @@ -371,6 +415,16 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy { }); 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.loadKycTemplate(); if (this.isNonIndividual && this.constitutionMeta) { @@ -412,9 +466,111 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy { // Reset selected document type since options changed this.kycForm.get('kycDocumentName')?.setValue(''); 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 { + 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 { + 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 { + 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) */ private loadExistingKycIfNeeded(): void { if (!this.clientId || this.kycLoaded) return; @@ -469,6 +625,10 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy { form15Type: data.form15Type || '15G', 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 if (data.panVerified) { 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')?.updateValueAndValidity(); + this.rebuildKycDocPicker(); } }, error: () => { // Fallback to defaults if template API unavailable this.kycTemplateLoaded = true; - this.kycDocumentTypes = this.defaultDocumentTypes.map((name) => ({ - name, - code: name, + this.kycDocumentTypes = this.defaultDocumentTypes.map((d) => ({ + name: d.name, + code: d.code, mandatory: false })); + this.rebuildKycDocPicker(); } }); } @@ -1232,6 +1394,7 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy { MatIcon, MatDialogContent, MatDialogActions, + MatDialogClose, STANDALONE_SHARED_IMPORTS ] }) diff --git a/src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.html b/src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.html index 8a428867d..6c0cb5670 100644 --- a/src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.html +++ b/src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.html @@ -77,6 +77,74 @@ }
+ +
+
+
+ + KYC Verification +
+ @if (kycComplete) { + + + KYC Done + + } @else if (panVerified || aadhaarVerified) { + + + Partially Verified + + } @else { + + + Not Verified + + } +
+ +
+
+
+ + PAN +
+ @if (panVerified) { +
+ {{ panNumber || '—' }} + @if (panHolderName) { + {{ panHolderName }} + } +
+ Verified + } @else { + {{ panNumber || 'Not provided' }} + Pending + } +
+ +
+
+ + Aadhaar +
+ @if (aadhaarVerified) { +
+ {{ aadhaarMasked || '—' }} + @if (aadhaarHolderName) { + {{ aadhaarHolderName }} + } +
+ Verified + } @else { + {{ + aadhaarMasked || 'Not provided' + }} + Pending + } +
+
+
+
diff --git a/src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.scss b/src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.scss index ba29cd983..66d527c48 100644 --- a/src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.scss +++ b/src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.scss @@ -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 ===================== */ .ckyc-info-grid { display: grid; diff --git a/src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.ts b/src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.ts index 26a2c6b17..8c7ecca1a 100644 --- a/src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.ts +++ b/src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.ts @@ -67,6 +67,18 @@ export class CkycTabComponent implements OnInit, OnDestroy { ckycBatchId = ''; 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 */ existing: Record<'ADDRESS_PROOF' | 'PHOTO_PROOF' | 'CLIENT_IMAGE' | 'CLIENT_SIGNATURE', ExistingArtefact> = { ADDRESS_PROOF: { present: false }, @@ -170,6 +182,13 @@ export class CkycTabComponent implements OnInit, OnDestroy { this.ckycNumber = data?.ckycNumber || ''; this.ckycUploadedOn = this.formatJavaDate(data?.ckycUploadedOn); 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 || ''; }); } diff --git a/src/app/clients/clients-view/custom-dialogs/upload-document-dialog/upload-document-dialog.component.ts b/src/app/clients/clients-view/custom-dialogs/upload-document-dialog/upload-document-dialog.component.ts index e52728d1c..22697da08 100644 --- a/src/app/clients/clients-view/custom-dialogs/upload-document-dialog/upload-document-dialog.component.ts +++ b/src/app/clients/clients-view/custom-dialogs/upload-document-dialog/upload-document-dialog.component.ts @@ -21,138 +21,10 @@ import { FileUploadComponent } from '../../../../shared/file-upload/file-upload. import { MatIconButton } from '@angular/material/button'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { ClientsService } from '../../../clients.service'; - -interface ProofType { - id: string | number; - name: string; - code?: string; - sides?: number; - category?: string; - documentKeyLabel?: string; - documentKeyPlaceholder?: string; - requiresDate?: boolean; - info?: string; -} +import { ProofType, normalizeProofType, groupProofsByCategory } from '../../../shared/kyc-proof-types'; 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> = { - 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({ selector: 'mifosx-upload-document-dialog', templateUrl: './upload-document-dialog.component.html', @@ -245,9 +117,9 @@ export class UploadDocumentDialogComponent implements OnInit { this.clientsService.getKycProofTypes().subscribe({ next: (res: any) => { const activeFilter = (p: any) => p?.active !== false; - this.idProofs = (res?.idProofs || []).filter(activeFilter).map((p: any) => this.normalizeProof(p)); - this.addressProofs = (res?.addressProofs || []).filter(activeFilter).map((p: any) => this.normalizeProof(p)); - this.buildAddressGroups(); + this.idProofs = (res?.idProofs || []).filter(activeFilter).map(normalizeProofType); + this.addressProofs = (res?.addressProofs || []).filter(activeFilter).map(normalizeProofType); + this.addressGroups = groupProofsByCategory(this.addressProofs); this.loadingProofs = false; }, 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(); - 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 { this.selectedIdProof = this.idProofs.find((p) => String(p.id) === String(proofId)) || null; this.idFiles = { front: null, back: null }; diff --git a/src/app/clients/clients-view/nominee-tab/nominee-tab.component.ts b/src/app/clients/clients-view/nominee-tab/nominee-tab.component.ts index eb6e4a236..99731db5f 100644 --- a/src/app/clients/clients-view/nominee-tab/nominee-tab.component.ts +++ b/src/app/clients/clients-view/nominee-tab/nominee-tab.component.ts @@ -294,7 +294,7 @@ export class NomineeTabComponent implements OnInit { } this.searching = true; this.searchTimer = setTimeout(() => { - this.clientsService.searchByText(text, 0, 8).subscribe({ + this.clientsService.searchByText(text, 0, 8, '', '', true).subscribe({ next: (data: any) => { this.searchResults = data?.content || []; this.searching = false; @@ -412,7 +412,7 @@ export class NomineeTabComponent implements OnInit { } this.guardianSearching = true; this.guardianSearchTimer = setTimeout(() => { - this.clientsService.searchByText(text, 0, 8).subscribe({ + this.clientsService.searchByText(text, 0, 8, '', '', true).subscribe({ next: (data: any) => { this.guardianSearchResults = data?.content || []; this.guardianSearching = false; diff --git a/src/app/clients/clients.service.ts b/src/app/clients/clients.service.ts index de9b603a2..3b7e201c4 100644 --- a/src/app/clients/clients.service.ts +++ b/src/app/clients/clients.service.ts @@ -576,11 +576,20 @@ export class ClientsService { 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 = { - request: { - text - }, + request: innerRequest, page, size: pageSize }; diff --git a/src/app/clients/create-client/create-client.component.ts b/src/app/clients/create-client/create-client.component.ts index 08f539a97..21571913f 100644 --- a/src/app/clients/create-client/create-client.component.ts +++ b/src/app/clients/create-client/create-client.component.ts @@ -72,9 +72,9 @@ export class CreateClientComponent { @ViewChild(ClientGeneralStepComponent) clientGeneralStep: ClientGeneralStepComponent; @ViewChild('clientAddress') clientAddressStep: ClientAddressStepComponent; @ViewChild('clientIndiaAddress') clientIndiaAddressStep: ClientIndiaAddressStepComponent; - @ViewChild('clientEntityKyc') clientEntityKycStep: ClientEntityKycStepComponent; + @ViewChild(ClientEntityKycStepComponent) clientEntityKycStep: ClientEntityKycStepComponent; @ViewChild('clientKyc') clientKycStep: ClientKycStepComponent; - @ViewChild('clientFamilyMembers') clientFamilyMembersStep: ClientFamilyMembersStepComponent; + @ViewChild(ClientFamilyMembersStepComponent) clientFamilyMembersStep: ClientFamilyMembersStepComponent; @ViewChildren('dtclient') clientDatatables: QueryList; datatables: any = []; @@ -250,6 +250,13 @@ export class CreateClientComponent { // KYC tab const kycTabIndex = 1; 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 if (this.clientKycStep?.hasKycData && !this.clientKycStep.kycId) { this.clientsService.saveClientKyc(clientId, this.clientKycStep.kycData).subscribe({ error: () => {} }); @@ -265,6 +272,8 @@ export class CreateClientComponent { .uploadClientSignatureImage(clientId, this.clientKycStep.signatureFile) .subscribe({ error: () => {} }); } + // Persist the KYC document picker selection + uploaded files as a client identifier + this.saveKycDocProof(clientId); return; } @@ -349,6 +358,14 @@ export class CreateClientComponent { validationError = ''; 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) 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.'; @@ -425,6 +442,9 @@ export class CreateClientComponent { 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 if (this.clientKycStep?.hasKycData && !this.clientKycStep.kycId) { this.clientsService.saveClientKyc(clientId, this.clientKycStep.kycData).subscribe({ @@ -514,6 +534,72 @@ export class CreateClientComponent { }, 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: () => {} + }); } /** diff --git a/src/app/clients/edit-client/edit-client.component.html b/src/app/clients/edit-client/edit-client.component.html index 9dcdd46df..c63b1a3cb 100644 --- a/src/app/clients/edit-client/edit-client.component.html +++ b/src/app/clients/edit-client/edit-client.component.html @@ -67,8 +67,8 @@
warning
- Re-KYC Required: Date of birth was changed. Please re-verify PAN and update the remarks - below before proceeding. + Re-KYC Required: Client name or date of birth was changed. Please re-verify PAN and + update the remarks below before proceeding.
} @@ -180,6 +180,8 @@ [addressData]="clientIndiaAddressStep?.addressData" [guardian]="clientGeneralStep?.selectedGuardian" [guardianRelation]="clientGeneralStep?.createClientForm?.get('guardianDetails.guardianRelation')?.value || ''" + [entityClassificationData]="clientEntityKycStep?.classificationData" + [familyMembers]="clientFamilyMembersStep?.clientFamilyMembers || []" [maritalStatusId]="clientGeneralStep?.createClientForm?.get('maritalStatusId')?.value" [maritalStatusOptions]="clientGeneralStep?.maritalStatusOptions || []" [educationLevelOptions]="clientGeneralStep?.educationLevelOptions || []" diff --git a/src/app/clients/edit-client/edit-client.component.ts b/src/app/clients/edit-client/edit-client.component.ts index 92f579192..c4a09aed4 100644 --- a/src/app/clients/edit-client/edit-client.component.ts +++ b/src/app/clients/edit-client/edit-client.component.ts @@ -69,9 +69,9 @@ export class EditClientComponent { @ViewChild(ClientGeneralStepComponent) clientGeneralStep: ClientGeneralStepComponent; @ViewChild('clientAddress') clientAddressStep: ClientAddressStepComponent; @ViewChild('clientIndiaAddress') clientIndiaAddressStep: ClientIndiaAddressStepComponent; - @ViewChild('clientEntityKyc') clientEntityKycStep: ClientEntityKycStepComponent; + @ViewChild(ClientEntityKycStepComponent) clientEntityKycStep: ClientEntityKycStepComponent; @ViewChild('clientKyc') clientKycStep: ClientKycStepComponent; - @ViewChild('clientFamilyMembers') clientFamilyMembersStep: ClientFamilyMembersStepComponent; + @ViewChild(ClientFamilyMembersStepComponent) clientFamilyMembersStep: ClientFamilyMembersStepComponent; @ViewChildren('dtclient') clientDatatables: QueryList; datatables: any = []; @@ -184,13 +184,13 @@ export class EditClientComponent { let dialogContext = 'Are you sure you want to save the changes'; 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) { const changes = this.detectSensitiveFieldChanges(this.clientDataAndTemplate); - if (changes.dobChanged) { + if (changes.nameChanged || changes.dobChanged) { heading = 'Re-KYC Required'; 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'; } } @@ -232,11 +232,11 @@ export class EditClientComponent { // Log mobile/email changes as client notes for risk audit this.logFieldChangeNotes(changedFields); - // If DOB changed, flag re-KYC requirement and reset PAN verification - if (changedFields.dobChanged) { + // If name or DOB changed, flag re-KYC requirement and reset PAN verification + if (changedFields.nameChanged || changedFields.dobChanged) { this.dobChanged = true; 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 @@ -249,6 +249,13 @@ export class EditClientComponent { if (changedFields.dobChanged) { 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; }, @@ -261,6 +268,11 @@ export class EditClientComponent { // KYC tab (always index 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; // Upload photo if selected @@ -317,6 +329,8 @@ export class EditClientComponent { if (!kycId && res?.resourceId) { 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; }, 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) { const form = this.clientGeneralForm; @@ -381,10 +395,38 @@ export class EditClientComponent { ? oldDobRaw.toISOString().split('T')[0] : 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 { + nameChanged: newName !== oldName, mobileChanged: newMobile !== oldMobile, emailChanged: newEmail !== oldEmail, dobChanged: newDob !== oldDob, + oldName, + newName, oldMobile, newMobile, 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 if (this.clientKycStep) { this.clientKycStep.panVerifyStatus = 'NONE'; this.clientKycStep.panVerifyName = ''; this.clientKycStep.panVerifiedOn = ''; // 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); } - // Also log DOB change as an audit note + // Also log the change as an audit note 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: () => {} }); } @@ -439,6 +497,13 @@ export class EditClientComponent { * Submits the edit client form. */ 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 dateFormat = this.settingsService.dateFormat; const clientData = { @@ -496,10 +561,14 @@ export class EditClientComponent { kycObs = this.clientsService.saveClientKyc(this.clientId, kycData); } kycObs.subscribe({ - next: () => navigateBack(), + next: () => { + this.saveKycDocProof(this.clientId); + navigateBack(); + }, error: () => navigateBack() }); } else { + this.saveKycDocProof(this.clientId); navigateBack(); } }, @@ -580,6 +649,72 @@ export class EditClientComponent { }, 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) { diff --git a/src/app/clients/shared/kyc-proof-types.ts b/src/app/clients/shared/kyc-proof-types.ts new file mode 100644 index 000000000..b2351fcbc --- /dev/null +++ b/src/app/clients/shared/kyc-proof-types.ts @@ -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> = { + 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(); + 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 }) + ); +} diff --git a/src/app/clients/shared/kyc-upload-helper.service.ts b/src/app/clients/shared/kyc-upload-helper.service.ts new file mode 100644 index 000000000..235a7e67b --- /dev/null +++ b/src/app/clients/shared/kyc-upload-helper.service.ts @@ -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 { + 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 { + 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; + }); + } +} diff --git a/src/app/core/authentication/credentials.model.ts b/src/app/core/authentication/credentials.model.ts index 597517cac..098729cad 100644 --- a/src/app/core/authentication/credentials.model.ts +++ b/src/app/core/authentication/credentials.model.ts @@ -23,6 +23,9 @@ export interface Credentials { roles: any; userId: number; username: string; + /** Optional contact details — populated when the auth response includes them. */ + email?: string; + mobileNo?: string; shouldRenewPassword: boolean; rememberMe?: boolean; } diff --git a/src/app/core/shell/shell.component.html b/src/app/core/shell/shell.component.html index 72c6c548f..8028aae7a 100644 --- a/src/app/core/shell/shell.component.html +++ b/src/app/core/shell/shell.component.html @@ -220,6 +220,9 @@ + diff --git a/src/app/core/shell/toolbar/toolbar.component.html b/src/app/core/shell/toolbar/toolbar.component.html index 5e59a8253..f73c1f5f5 100644 --- a/src/app/core/shell/toolbar/toolbar.component.html +++ b/src/app/core/shell/toolbar/toolbar.component.html @@ -342,6 +342,12 @@ {{ 'labels.menus.Profile' | translate }} +
diff --git a/src/app/login/login.component.ts b/src/app/login/login.component.ts index ebd460339..746454811 100644 --- a/src/app/login/login.component.ts +++ b/src/app/login/login.component.ts @@ -43,6 +43,7 @@ import { TenantSelectorComponent } from '../shared/tenant-selector/tenant-select import { LoginFormComponent } from './login-form/login-form.component'; import { ResetPasswordComponent } from './reset-password/reset-password.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 { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; @@ -216,6 +217,8 @@ export class LoginComponent implements OnInit, OnDestroy { private alertService = inject(AlertService); private settingsService = inject(SettingsService); private themingService = inject(ThemingService); + private dialog = inject(MatDialog); + private twoFactorDialogRef: MatDialogRef | null = null; private router = inject(Router); private versionService = inject(VersionService); @@ -270,13 +273,16 @@ export class LoginComponent implements OnInit, OnDestroy { const alertType = alertEvent.type; if (alertType === 'Password Expired') { this.twoFactorAuthenticationRequired = false; + this.closeTwoFactorDialog(); this.resetPassword = true; } else if (alertType === 'Two Factor Authentication Required') { this.resetPassword = false; this.twoFactorAuthenticationRequired = true; + this.openTwoFactorDialog(); } else if (alertType === 'Authentication Success') { this.resetPassword = false; this.twoFactorAuthenticationRequired = false; + this.closeTwoFactorDialog(); this.router.navigate(['/'], { replaceUrl: true }); } else if (alertType === 'Tenant Changed') { this.updateLogo(); @@ -323,9 +329,34 @@ export class LoginComponent implements OnInit, OnDestroy { if (this.theme$) { this.theme$.unsubscribe(); } + this.closeTwoFactorDialog(); 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 { this.currentSlide = index; this.restartSlider(); diff --git a/src/app/login/two-factor-authentication/two-factor-authentication.component.html b/src/app/login/two-factor-authentication/two-factor-authentication.component.html index a233e7a48..6122beb42 100644 --- a/src/app/login/two-factor-authentication/two-factor-authentication.component.html +++ b/src/app/login/two-factor-authentication/two-factor-authentication.component.html @@ -6,74 +6,149 @@ file, You can obtain one at http://mozilla.org/MPL/2.0/. --> -

- {{ 'labels.text.Two Factor Authentication' | translate }} -

+
+
+ +
- +

{{ 'labels.text.Two Factor Authentication' | translate }}

+

+ @if (!otpRequested) { + Choose how you want to receive your one-time passcode. + } @else { + Enter the 6-digit code we sent you to continue. + } +

- -@if (!otpRequested) { -

{{ 'labels.text.Please select a delivery method' | translate }}:

+ + @if (!otpRequested) { +
+
+ + @for (method of twoFactorAuthenticationDeliveryMethods; track method.name) { + + } + +
- - - @for ( - twoFactorAuthenticationDeliveryMethod of twoFactorAuthenticationDeliveryMethods; - track twoFactorAuthenticationDeliveryMethod - ) { - - {{ 'labels.inputs.Send' | translate }} {{ twoFactorAuthenticationDeliveryMethod.name }} - {{ 'labels.inputs.to' | translate }} {{ twoFactorAuthenticationDeliveryMethod.target }} - - } - - -
-} + + + } - -@if (otpRequested) { -

{{ 'labels.text.Please enter the OTP' | translate }}:

- -
- - - + + @if (otpRequested) { +
+ +
+ Code sent to + {{ maskTarget(selectedTwoFactorAuthenticationDeliveryMethod) }} +
+ + + {{ tokenValidityTime / 60 }} {{ 'labels.inputs.mins' | translate }} - {{ 'labels.inputs.OTP' | translate }} - - - {{ 'labels.inputs.Delivery Method' | translate }}: - {{ selectedTwoFactorAuthenticationDeliveryMethod.name }} - - - {{ 'labels.inputs.Validity' | translate }}: {{ tokenValidityTime / 60 }} - {{ 'labels.inputs.mins' | translate }} - - @if (twoFactorAuthenticationForm.controls.otp.hasError('required')) { - +
+ + +
+ +
+ + @if ( + twoFactorAuthenticationForm.controls.otp.hasError('required') && + twoFactorAuthenticationForm.controls.otp.touched + ) { +
+ {{ 'labels.inputs.OTP' | translate }} {{ 'labels.commons.is' | translate }} {{ 'labels.commons.required' | translate }} - +
} -
- - -
-} + + + + + + } +
diff --git a/src/app/login/two-factor-authentication/two-factor-authentication.component.scss b/src/app/login/two-factor-authentication/two-factor-authentication.component.scss index f7ad82c62..1728e0d4d 100644 --- a/src/app/login/two-factor-authentication/two-factor-authentication.component.scss +++ b/src/app/login/two-factor-authentication/two-factor-authentication.component.scss @@ -6,180 +6,338 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -// ===== Host Container ===== :host { display: flex; - flex-direction: column; width: 100%; - animation: fade-in-up 0.4s ease-out; + justify-content: center; + animation: tfa-fade 0.4s ease-out; } -// ===== Title Section ===== -p { - text-align: center; - font-size: 1.1rem; - color: rgb(255 255 255 / 90%); - margin: 0 0 1rem; - - strong { - 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 { +.tfa-card { + width: 100%; + max-width: 400px; + padding: 24px 22px 22px; + border-radius: 18px; + background: rgb(255 255 255 / 6%); + border: 1px solid rgb(255 255 255 / 12%); + backdrop-filter: blur(8px); display: flex; 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; - width: 100%; - gap: 1rem; + justify-content: center; + font-size: 24px; + box-shadow: + 0 6px 20px rgb(37 99 235 / 35%), + inset 0 1px 0 rgb(255 255 255 / 25%); + margin-bottom: 14px; +} - mat-radio-button { - margin-bottom: 0.25rem; +.tfa-title { + margin: 0 0 6px; + text-align: center; + font-size: 18px; + font-weight: 700; + color: #fff; + letter-spacing: 0.2px; +} - &:last-child { - margin-bottom: 0; - } +.tfa-sub { + margin: 0 0 20px; + text-align: center; + font-size: 13px; + color: rgb(255 255 255 / 65%); + line-height: 1.5; +} - // Style radio buttons to match Material Design 3 - ::ng-deep { - .mdc-form-field { - font-size: 0.95rem; - color: var(--md-sys-color-on-surface, #1a1c1e); - } +/* ──────── Form layout ──────── */ +.tfa-form { + display: flex; + flex-direction: column; + gap: 12px; +} - .mdc-radio__outer-circle { - border-color: var(--md-sys-color-on-surface-variant, #44474e); - } +/* ──────── Step 1 — delivery methods ──────── */ +.tfa-methods__group { + display: flex; + flex-direction: column; + gap: 10px; +} - .mdc-radio--selected .mdc-radio__outer-circle, - .mdc-radio--selected .mdc-radio__inner-circle { - border-color: var(--md-sys-color-primary, #1074b9); - } - } +.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%); } - // ===== OTP Input Field ===== - .two-factor-auth-input { - width: 100%; - max-width: 320px; - - [matPrefix] { - padding-left: 1rem; - - fa-icon { - margin-right: 0.75rem; - } - } - - ::ng-deep { - .mat-mdc-text-field-wrapper { - border-radius: 12px 12px 0 0; - } - - .mat-mdc-form-field-focus-overlay { - border-radius: 12px 12px 0 0; - background-color: var(--md-sys-color-on-surface, #1a1c1e); - opacity: 0.04; - } - - .mdc-line-ripple::after { - border-bottom-color: var(--md-sys-color-primary, #1074b9); - } - - .mat-mdc-form-field-hint-wrapper { - padding: 0.5rem 1rem; - } - - mat-hint { - font-size: 0.75rem; - color: var(--md-sys-color-on-surface-variant, #44474e); - } - } + &.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%); } - // ===== Buttons ===== - .two-factor-auth-button, - [mat-raised-button], - [mat-button] { - width: 100%; - max-width: 320px; - height: 48px; - border-radius: 24px; - font-size: 0.95rem; - font-weight: 500; - letter-spacing: 0.02em; - transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); - display: flex; + &__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; - gap: 0.5rem; + font-size: 14px; + flex-shrink: 0; } - [mat-raised-button] { - background: var(--md-sys-color-primary, #1074b9); - color: var(--md-sys-color-on-primary, #fff); - box-shadow: - 0 1px 2px rgb(0 0 0 / 30%), - 0 1px 3px 1px rgb(0 0 0 / 15%); - - &:hover:not([disabled]) { - box-shadow: - 0 1px 3px rgb(0 0 0 / 30%), - 0 4px 8px 3px rgb(0 0 0 / 15%); - } - - &[disabled] { - background: var(--md-sys-color-on-surface, #1a1c1e); - opacity: 0.38; - } + &.is-selected &__icon { + background: #1d4ed8; + color: #fff; } - [mat-button] { - color: var(--md-sys-color-primary, #1074b9); - background: transparent; - - &:hover:not([disabled]) { - background: rgb(16 116 185 / 8%); - } + &__body { + display: flex; + flex-direction: column; + min-width: 0; + flex: 1; } - mat-spinner { - display: inline-block; - margin-left: 0.5rem; + &__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; + } } } -// ===== Radio Group Spacing ===== -.radio-group-spacing { +/* ──────── Step 2 — context strip ──────── */ +.tfa-context { display: flex; - flex-direction: column; align-items: center; - gap: 0.75rem; - width: 100%; - padding: 0.5rem 0; - margin-bottom: 0.5rem; + 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%); + + > fa-icon { + color: #93c5fd; + font-size: 16px; + flex-shrink: 0; + } + + > div { + display: flex; + flex-direction: column; + flex: 1; + min-width: 0; + } + + &__label { + font-size: 11px; + color: rgb(255 255 255 / 55%); + letter-spacing: 0.4px; + text-transform: uppercase; + } + + &__target { + font-size: 13px; + font-weight: 700; + color: #fff; + word-break: break-all; + } + + &__validity { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 4px 10px; + border-radius: 999px; + background: rgb(34 197 94 / 18%); + color: #86efac; + border: 1px solid rgb(34 197 94 / 35%); + font-size: 11px; + font-weight: 700; + flex-shrink: 0; + + fa-icon { + font-size: 10px; + } + } } -// ===== Animations ===== -@keyframes fade-in-up { +/* ──────── Step 2 — OTP input ──────── */ +.tfa-otp { + display: flex; + justify-content: center; +} + +.tfa-otp__input { + width: 100%; + max-width: 320px; + height: 60px; + text-align: center; + border: 1px solid rgb(255 255 255 / 18%); + background: rgb(255 255 255 / 8%); + border-radius: 14px; + color: #fff; + font-family: 'SF Mono', 'Cascadia Code', Consolas, monospace; + font-size: 28px; + font-weight: 700; + letter-spacing: 14px; + padding: 0 0 0 14px; + outline: none; + transition: + border-color 0.18s, + box-shadow 0.18s, + background 0.18s; + + &::placeholder { + color: rgb(255 255 255 / 25%); + letter-spacing: 14px; + } + + &:focus { + border-color: #60a5fa; + background: rgb(255 255 255 / 12%); + box-shadow: 0 0 0 4px rgb(96 165 250 / 25%); + } +} + +.tfa-error { + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 8px 12px; + border-radius: 10px; + background: rgb(239 68 68 / 14%); + border: 1px solid rgb(239 68 68 / 35%); + color: #fecaca; + font-size: 12.5px; + + fa-icon { + font-size: 13px; + color: #fca5a5; + } + + strong { + color: #fff; + font-weight: 700; + } +} + +/* ──────── Buttons ──────── */ +.tfa-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + width: 100%; + height: 48px; + border-radius: 12px; + font-size: 14px; + font-weight: 700; + letter-spacing: 0.3px; + cursor: pointer; + transition: + background 0.18s, + box-shadow 0.18s, + transform 0.18s, + opacity 0.18s; + border: 1px solid transparent; + + fa-icon { + font-size: 14px; + } + + &:disabled { + cursor: not-allowed; + opacity: 0.6; + } + + &--primary { + background: linear-gradient(135deg, #2563eb 0%, #1e40af 100%); + color: #fff; + box-shadow: + 0 6px 20px rgb(37 99 235 / 32%), + inset 0 1px 0 rgb(255 255 255 / 18%); + + &:hover:not(:disabled) { + transform: translateY(-1px); + box-shadow: + 0 10px 24px rgb(37 99 235 / 42%), + inset 0 1px 0 rgb(255 255 255 / 22%); + } + } + + &--ghost { + background: transparent; + color: rgb(255 255 255 / 80%); + border-color: rgb(255 255 255 / 18%); + + &:hover:not(:disabled) { + background: rgb(255 255 255 / 8%); + color: #fff; + border-color: rgb(255 255 255 / 30%); + } + } + + mat-spinner ::ng-deep circle { + stroke: #fff !important; + } +} + +/* ──────── Animations ──────── */ +@keyframes tfa-fade { from { opacity: 0; - transform: translateY(10px); + transform: translateY(8px); } to { @@ -188,61 +346,16 @@ p:not(:first-of-type) { } } -// ===== Dark Mode Support ===== -:host-context(.dark-theme), -:host-context([data-theme='dark']) { - p { - color: var(--md-sys-color-on-surface, #e2e2e5); - - strong { - color: inherit; - } +/* ──────── Responsive ──────── */ +@media (width <= 480px) { + .tfa-card { + padding: 20px 16px; + border-radius: 16px; } - p:not(:first-of-type) { - color: var(--md-sys-color-on-surface-variant, #c4c6cf); - } - - .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%; - } + .tfa-otp__input { + font-size: 22px; + letter-spacing: 10px; + padding-left: 10px; } } diff --git a/src/app/login/two-factor-authentication/two-factor-authentication.component.ts b/src/app/login/two-factor-authentication/two-factor-authentication.component.ts index cd9cb0991..1e05cf143 100644 --- a/src/app/login/two-factor-authentication/two-factor-authentication.component.ts +++ b/src/app/login/two-factor-authentication/two-factor-authentication.component.ts @@ -15,10 +15,8 @@ import { finalize } from 'rxjs/operators'; /** Custom Services */ import { AuthenticationService } from '../../core/authentication/authentication.service'; -import { MatDivider } from '@angular/material/divider'; import { MatRadioGroup, MatRadioButton } from '@angular/material/radio'; 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 { 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'], imports: [ ...STANDALONE_SHARED_IMPORTS, - MatDivider, MatRadioGroup, MatRadioButton, MatProgressSpinner, - MatPrefix, - FaIconComponent, - MatHint + FaIconComponent ] }) export class TwoFactorAuthenticationComponent implements OnInit { @@ -69,7 +64,18 @@ export class TwoFactorAuthenticationComponent implements OnInit { ngOnInit() { this.createTwoFactorAuthenticationDeliveryMethodForm(); 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(); } + /** 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. */ diff --git a/src/app/otp-audit-logs/otp-audit-logs-routing.module.ts b/src/app/otp-audit-logs/otp-audit-logs-routing.module.ts new file mode 100644 index 000000000..b57e48837 --- /dev/null +++ b/src/app/otp-audit-logs/otp-audit-logs-routing.module.ts @@ -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 {} diff --git a/src/app/otp-audit-logs/otp-audit-logs.component.html b/src/app/otp-audit-logs/otp-audit-logs.component.html new file mode 100644 index 000000000..9765cb859 --- /dev/null +++ b/src/app/otp-audit-logs/otp-audit-logs.component.html @@ -0,0 +1,186 @@ + + +
+
+
+
+

OTP Audit Logs

+

All OTP send, verify and resend events. Used for auditing MFA and login activity.

+
+
+ + +
+
+ + +
+ + Search + search + + + + + Status + + @for (s of statusOptions; track s.value) { + {{ s.label }} + } + + + + + Channel + + @for (c of channelOptions; track c.value) { + {{ c.label }} + } + + + + + From + + + + + + + To + + + + + +
+ + +
+
+ + +
+ @if (loading) { + + } +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ID#{{ row.id }}Created{{ formatDate(row.createdOn) }}User{{ row.username || '—' }}Channel + + {{ + row.channel === 'EMAIL' ? 'mail' : row.channel === 'TOTP' ? 'qr_code_2' : 'smartphone' + }} + {{ row.channel || '—' }} + + Destination{{ row.destination || '—' }}Status + {{ row.status || '—' }} + Attempts + {{ row.attempts ?? 0 }}{{ row.maxAttempts ? '/' + row.maxAttempts : '' }} + Verified{{ formatDate(row.verifiedOn) }}Actions + +
+ + @if (!loading && errorStatus) { +
+ {{ errorStatus === 401 || errorStatus === 403 ? 'lock' : 'error_outline' }} +
+ + @if (errorStatus === 403) { + Access denied (HTTP 403) + } @else if (errorStatus === 401) { + Sign in required (HTTP 401) + } @else { + Failed to load (HTTP {{ errorStatus }}) + } + + {{ errorMsg }} + @if (errorStatus === 403) { + + Your role doesn't have permission to read OTP logs. Ask an admin to add the + READ_OTP_LOG permission to your role under + Admin → System → Manage Roles and Permissions. + + } +
+
+ } @else if (!loading && dataSource.data.length === 0) { +
+ inbox +

No OTP logs match the current filters.

+
+ } +
+ + +
+
diff --git a/src/app/otp-audit-logs/otp-audit-logs.component.scss b/src/app/otp-audit-logs/otp-audit-logs.component.scss new file mode 100644 index 000000000..1cbeb6f72 --- /dev/null +++ b/src/app/otp-audit-logs/otp-audit-logs.component.scss @@ -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; + } +} diff --git a/src/app/otp-audit-logs/otp-audit-logs.component.ts b/src/app/otp-audit-logs/otp-audit-logs.component.ts new file mode 100644 index 000000000..8f8fb7a1d --- /dev/null +++ b/src/app/otp-audit-logs/otp-audit-logs.component.ts @@ -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([]); + /** 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}`; + } +} diff --git a/src/app/otp-audit-logs/otp-audit-logs.module.ts b/src/app/otp-audit-logs/otp-audit-logs.module.ts new file mode 100644 index 000000000..8945f1046 --- /dev/null +++ b/src/app/otp-audit-logs/otp-audit-logs.module.ts @@ -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 {} diff --git a/src/app/otp-audit-logs/otp-audit-logs.service.ts b/src/app/otp-audit-logs/otp-audit-logs.service.ts new file mode 100644 index 000000000..8b278ff19 --- /dev/null +++ b/src/app/otp-audit-logs/otp-audit-logs.service.ts @@ -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 { + 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('/otp-logs', { params }); + } + + /** Detail view */ + get(id: number | string): Observable { + return this.http.get(`/otp-logs/${id}`); + } +} diff --git a/src/app/otp-audit-logs/view-otp-log/view-otp-log.component.html b/src/app/otp-audit-logs/view-otp-log/view-otp-log.component.html new file mode 100644 index 000000000..1915609af --- /dev/null +++ b/src/app/otp-audit-logs/view-otp-log/view-otp-log.component.html @@ -0,0 +1,92 @@ + + +
+ + + @if (loading) { + + } + + @if (!loading && error) { +
error_outline {{ error }}
+ } + + @if (!loading && log) { +
+
+
+ {{ + log.channel === 'EMAIL' ? 'mail' : log.channel === 'TOTP' ? 'qr_code_2' : 'smartphone' + }} +
+
+

+ OTP Log #{{ log.id }} +

+ + {{ log.channel || '—' }} + {{ log.status || '—' }} + @if (log.purpose) { + {{ log.purpose }} + } + +
+
+ +
+
+ User + {{ log.username || '—' }} +
+
+ Destination + {{ log.destination || '—' }} +
+
+ Reference + {{ log.otpReference || '—' }} +
+
+ Attempts + + {{ log.attempts ?? 0 }}{{ log.maxAttempts ? '/' + log.maxAttempts : '' }} + +
+
+ Created on + {{ formatDate(log.createdOn) }} +
+
+ Expires on + {{ formatDate(log.expiresOn) }} +
+
+ Verified on + {{ formatDate(log.verifiedOn) }} +
+
+ IP address + {{ log.ipAddress || '—' }} +
+ @if (log.userAgent) { +
+ User agent + {{ log.userAgent }} +
+ } + @if (log.message) { +
+ Gateway message + {{ log.message }} +
+ } +
+
+ } +
diff --git a/src/app/otp-audit-logs/view-otp-log/view-otp-log.component.scss b/src/app/otp-audit-logs/view-otp-log/view-otp-log.component.scss new file mode 100644 index 000000000..5cd5b7857 --- /dev/null +++ b/src/app/otp-audit-logs/view-otp-log/view-otp-log.component.scss @@ -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; +} diff --git a/src/app/otp-audit-logs/view-otp-log/view-otp-log.component.ts b/src/app/otp-audit-logs/view-otp-log/view-otp-log.component.ts new file mode 100644 index 000000000..16e777486 --- /dev/null +++ b/src/app/otp-audit-logs/view-otp-log/view-otp-log.component.ts @@ -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'; + } +} diff --git a/src/app/shared/styles/list-layout.scss b/src/app/shared/styles/list-layout.scss index 086ec3c0c..8ddbe01e0 100644 --- a/src/app/shared/styles/list-layout.scss +++ b/src/app/shared/styles/list-layout.scss @@ -456,6 +456,11 @@ $mono: 'Roboto Mono', 'DM Mono', monospace; color: #475569; } +.cell-empty { + color: #94a3b8; + font-style: italic; +} + // ===== Status Badge ===== .status-badge { display: inline-flex; diff --git a/src/app/users/create-user/create-user.component.html b/src/app/users/create-user/create-user.component.html index bfa0a7d55..096865728 100644 --- a/src/app/users/create-user/create-user.component.html +++ b/src/app/users/create-user/create-user.component.html @@ -27,7 +27,8 @@
{{ 'labels.inputs.Username' | translate }} - + person + @if (userForm.controls.username.hasError('required')) { {{ 'labels.inputs.Username' | translate }} {{ 'labels.commons.is' | translate }} @@ -36,26 +37,10 @@ } - - {{ 'labels.inputs.Email' | translate }} - - @if (userForm.controls.email.hasError('email')) { - {{ 'labels.inputs.Email' | translate }} {{ 'labels.commons.is' | translate }} - {{ 'labels.commons.invalid' | translate }} - } - @if (userForm.controls.email.hasError('required')) { - {{ 'labels.inputs.Email' | translate }} {{ 'labels.commons.is' | translate }} - {{ 'labels.commons.required' | translate }} - } - - {{ 'labels.inputs.First Name' | translate }} - + badge + @if (userForm.controls.firstname.hasError('pattern')) { {{ 'labels.inputs.First Name' | translate }} @@ -74,7 +59,8 @@ {{ 'labels.inputs.Last Name' | translate }} - + person_outline + @if (userForm.controls.lastname.hasError('pattern')) { {{ 'labels.inputs.Last Name' | translate }} @@ -90,6 +76,54 @@ > } + + + {{ 'labels.inputs.Email' | translate }} + email + + @if (userForm.controls.email.hasError('email')) { + {{ 'labels.inputs.Email' | translate }} {{ 'labels.commons.is' | translate }} + {{ 'labels.commons.invalid' | translate }} + } + @if (userForm.controls.email.hasError('required')) { + {{ 'labels.inputs.Email' | translate }} {{ 'labels.commons.is' | translate }} + {{ 'labels.commons.required' | translate }} + } + + + + Mobile Number + + phone_android + +91 + + + @if (userForm.controls.mobileNo.hasError('required')) { + Mobile number {{ 'labels.commons.is' | translate }} + {{ 'labels.commons.required' | translate }} + + } + @if (userForm.controls.mobileNo.hasError('pattern')) { + Enter a valid 10-digit mobile number + } +
@@ -179,6 +213,7 @@
{{ 'labels.inputs.Office' | translate }} + business @for (office of officesData; track office) { {{ office.name }} @@ -194,6 +229,7 @@ {{ 'labels.inputs.Staff' | translate }} + work @for (staff of staffData; track staff) { {{ staff.displayName }} @@ -203,6 +239,7 @@ {{ 'labels.inputs.Roles' | translate }} + verified_user @for (role of rolesData; track role) { {{ role.name }} diff --git a/src/app/users/create-user/create-user.component.scss b/src/app/users/create-user/create-user.component.scss index f1efbba06..d76540d5d 100644 --- a/src/app/users/create-user/create-user.component.scss +++ b/src/app/users/create-user/create-user.component.scss @@ -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 { grid-column: 1 / -1; display: flex; diff --git a/src/app/users/create-user/create-user.component.ts b/src/app/users/create-user/create-user.component.ts index 6a0884830..e785391e6 100644 --- a/src/app/users/create-user/create-user.component.ts +++ b/src/app/users/create-user/create-user.component.ts @@ -28,6 +28,7 @@ import { confirmPasswordValidator } from 'app/login/reset-password/confirm-passw import { ConfigurationWizardService } from 'app/configuration-wizard/configuration-wizard.service'; import { ContinueSetupDialogComponent } from 'app/configuration-wizard/continue-setup-dialog/continue-setup-dialog.component'; import { MatCheckbox } from '@angular/material/checkbox'; +import { MatIcon } from '@angular/material/icon'; 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'], imports: [ ...STANDALONE_SHARED_IMPORTS, - MatCheckbox + MatCheckbox, + MatIcon ] }) export class CreateUserComponent implements OnInit, AfterViewInit { @@ -108,6 +110,13 @@ export class CreateUserComponent implements OnInit, AfterViewInit { Validators.email ] ], + mobileNo: [ + '', + [ + Validators.required, + Validators.pattern(/^\d{10}$/) + ] + ], firstname: [ '', [ diff --git a/src/app/users/edit-user/edit-user.component.html b/src/app/users/edit-user/edit-user.component.html index a5db4c43c..c84caee31 100644 --- a/src/app/users/edit-user/edit-user.component.html +++ b/src/app/users/edit-user/edit-user.component.html @@ -27,7 +27,8 @@
{{ 'labels.inputs.Username' | translate }} - + person + @if (editUserForm.controls.username.hasError('required')) { {{ 'labels.inputs.Username' | translate }} {{ 'labels.commons.is' | translate }} @@ -36,26 +37,10 @@ } - - {{ 'labels.inputs.Email' | translate }} - - @if (editUserForm.controls.email.hasError('email')) { - {{ 'labels.inputs.Email' | translate }} {{ 'labels.commons.is' | translate }} - {{ 'labels.commons.invalid' | translate }} - } - @if (editUserForm.controls.email.hasError('required')) { - {{ 'labels.inputs.Email' | translate }} {{ 'labels.commons.is' | translate }} - {{ 'labels.commons.required' | translate }} - } - - {{ 'labels.inputs.First Name' | translate }} - + badge + @if (editUserForm.controls.firstname.hasError('pattern')) { {{ 'labels.inputs.First Name' | translate }} @@ -74,7 +59,8 @@ {{ 'labels.inputs.Last Name' | translate }} - + person_outline + @if (editUserForm.controls.lastname.hasError('pattern')) { {{ 'labels.inputs.Last Name' | translate }} @@ -90,6 +76,49 @@ > } + + + {{ 'labels.inputs.Email' | translate }} + email + + @if (editUserForm.controls.email.hasError('email')) { + {{ 'labels.inputs.Email' | translate }} {{ 'labels.commons.is' | translate }} + {{ 'labels.commons.invalid' | translate }} + } + @if (editUserForm.controls.email.hasError('required')) { + {{ 'labels.inputs.Email' | translate }} {{ 'labels.commons.is' | translate }} + {{ 'labels.commons.required' | translate }} + } + + + + Mobile Number + + phone_android + +91 + + + @if (editUserForm.controls.mobileNo.hasError('required')) { + Mobile number {{ 'labels.commons.is' | translate }} + {{ 'labels.commons.required' | translate }} + + } + @if (editUserForm.controls.mobileNo.hasError('pattern')) { + Enter a valid 10-digit mobile number + } +
@@ -117,6 +146,7 @@
{{ 'labels.inputs.Office' | translate }} + business @for (office of officesData; track office) { {{ office.name }} @@ -132,6 +162,7 @@ {{ 'labels.inputs.Staff' | translate }} + work @for (staff of staffData; track staff) { {{ staff.displayName }} @@ -141,6 +172,7 @@ {{ 'labels.inputs.Roles' | translate }} + verified_user @for (role of rolesData; track role) { {{ role.name }} diff --git a/src/app/users/edit-user/edit-user.component.scss b/src/app/users/edit-user/edit-user.component.scss index c19dcd5c4..fe21c9b87 100644 --- a/src/app/users/edit-user/edit-user.component.scss +++ b/src/app/users/edit-user/edit-user.component.scss @@ -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 { grid-column: 1 / -1; display: flex; diff --git a/src/app/users/edit-user/edit-user.component.ts b/src/app/users/edit-user/edit-user.component.ts index e6d21661a..ac6b875af 100644 --- a/src/app/users/edit-user/edit-user.component.ts +++ b/src/app/users/edit-user/edit-user.component.ts @@ -14,6 +14,7 @@ import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } /** Custom Services */ import { UsersService } from '../users.service'; import { MatCheckbox } from '@angular/material/checkbox'; +import { MatIcon } from '@angular/material/icon'; 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'], imports: [ ...STANDALONE_SHARED_IMPORTS, - MatCheckbox + MatCheckbox, + MatIcon ] }) export class EditUserComponent implements OnInit { @@ -82,6 +84,13 @@ export class EditUserComponent implements OnInit { Validators.email ] ], + mobileNo: [ + this.userData.mobileNo || '', + [ + Validators.required, + Validators.pattern(/^\d{10}$/) + ] + ], firstname: [ this.userData.firstname, [ diff --git a/src/app/users/users.component.html b/src/app/users/users.component.html index 443599f73..9e268a2de 100644 --- a/src/app/users/users.component.html +++ b/src/app/users/users.component.html @@ -60,24 +60,22 @@ {{ user.email }} + + Mobile + + @if (user.mobileNo) { + +91 {{ user.mobileNo }} + } @else { + — + } + + + {{ 'labels.inputs.Office' | translate }} {{ user.officeName }} - - {{ 'labels.buttons.Is Self Service' | translate }} - - - {{ user.isSelfServiceUser ? ('labels.buttons.Yes' | translate) : ('labels.buttons.No' | translate) }} - - - - diff --git a/src/app/users/users.component.ts b/src/app/users/users.component.ts index 5a5aaf8a1..21e6de750 100644 --- a/src/app/users/users.component.ts +++ b/src/app/users/users.component.ts @@ -73,8 +73,8 @@ export class UsersComponent implements OnInit, AfterViewInit { 'firstname', 'lastname', 'email', - 'officeName', - 'isSelfServiceUser' + 'mobileNo', + 'officeName' ]; /** Data source for users table. */ dataSource: MatTableDataSource; diff --git a/src/app/users/view-user/view-user.component.html b/src/app/users/view-user/view-user.component.html index aea1e1c6c..1b8676d00 100644 --- a/src/app/users/view-user/view-user.component.html +++ b/src/app/users/view-user/view-user.component.html @@ -72,6 +72,13 @@
} + @if (userData.mobileNo) { +
+
Mobile
+
+91 {{ userData.mobileNo }}
+
+ } +
{{ 'labels.inputs.Roles' | translate }}
diff --git a/src/environments/.env.ts b/src/environments/.env.ts index 2a3c8fdfb..77fbcad14 100644 --- a/src/environments/.env.ts +++ b/src/environments/.env.ts @@ -9,8 +9,8 @@ /* tslint:disable */ export default { 'mifos_x': { - 'version': '260429', - 'hash': '0d3d8379' + 'version': '260509', + 'hash': 'g2d4fb4237' }, 'allow_switching_backend_instance': true }; diff --git a/src/main.scss b/src/main.scss index 96bbc55f4..a490be64e 100644 --- a/src/main.scss +++ b/src/main.scss @@ -1041,3 +1041,29 @@ mat-card { 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); +}