feat: MFA/2FA, OTP audit logs, KYC + client/user enhancements

- Access Control module: SMS/Email/TOTP/Hardware-token MFA factor management
- OTP Audit Logs module under Admin menu (list + detail) with 401/403 handling
- Two-factor authentication login flow updates
- Client KYC, India address, family-members, ckyc, nominee enhancements
- New shared KYC helpers (kyc-proof-types, kyc-upload-helper.service)
- User create/edit/view enhancements
- Shell navigation: OTP Audit Logs entry under real Admin menu
This commit is contained in:
Aditya
2026-05-10 13:24:48 +05:30
parent 2d4fb4237e
commit 3b752dc6ae
56 changed files with 7741 additions and 599 deletions
@@ -0,0 +1,29 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { Route } from '../core/route/route.service';
import { AccessControlComponent } from './access-control.component';
const routes: Routes = [
Route.withShell([
{
path: 'access-control',
component: AccessControlComponent,
data: { title: 'Access Control', breadcrumb: 'Access Control' }
}
])
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class AccessControlRoutingModule {}
@@ -0,0 +1,843 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="ac-container">
<!-- Page header -->
<div class="ac-page-head">
<div class="ac-page-head__icon"><fa-icon icon="shield-alt"></fa-icon></div>
<div class="ac-page-head__text">
<h2>Access Control · Multi-Factor Authentication</h2>
<p>
Pick a method to set up. The factor stays disabled until verification or registration completes for the
signed-in user <strong>{{ username }}</strong
>.
</p>
</div>
<div class="ac-page-head__status" [class.is-on]="anyMethodActive">
<mat-icon>{{ anyMethodActive ? 'lock' : 'lock_open' }}</mat-icon>
{{ anyMethodActive ? 'MFA Active' : 'MFA Off' }}
</div>
</div>
<!-- ════════════ METHOD PICKER (default view) ════════════ -->
@if (!selectedMethod) {
<section class="ac-section">
<header class="ac-section__head">
<div class="ac-section__num"><mat-icon>add_task</mat-icon></div>
<div>
<h3 class="ac-section__title">Select an authentication method</h3>
<p class="ac-section__sub">
Choose how you want to confirm sign-in. Only one method may be active at a time.
</p>
</div>
</header>
@if (activeMethod) {
<div class="ac-active-banner">
<mat-icon>info</mat-icon>
<span>
<strong>{{
activeMethod === 'sms'
? 'SMS OTP'
: activeMethod === 'email'
? 'Email OTP'
: activeMethod === 'totp'
? 'Authenticator App'
: 'Hardware Token'
}}</strong>
is currently active. Disable it first to switch to a different method.
</span>
</div>
}
<div class="ac-picker">
<!-- SMS -->
<button
class="ac-tile"
type="button"
[class.ac-tile--locked]="isMethodLocked('sms')"
[class.ac-tile--selected]="isMethodPicked('sms') || activeMethod === 'sms'"
[class.ac-tile--active]="activeMethod === 'sms'"
[disabled]="isMethodLocked('sms')"
(click)="pickMethod('sms')"
>
<div class="ac-tile__top">
<div class="ac-tile__icon ac-tile__icon--blue"><mat-icon>smartphone</mat-icon></div>
<span
class="ac-tile__radio"
[class.is-on]="isMethodPicked('sms') || activeMethod === 'sms'"
[class.is-locked]="isMethodLocked('sms')"
[attr.aria-checked]="isMethodPicked('sms') || activeMethod === 'sms'"
role="radio"
>
@if (isMethodLocked('sms')) {
<mat-icon>lock</mat-icon>
} @else if (isMethodPicked('sms') || activeMethod === 'sms') {
<mat-icon>check</mat-icon>
}
</span>
</div>
<h4 class="ac-tile__title">SMS OTP</h4>
<p class="ac-tile__desc">Receive a one-time code on your registered mobile number.</p>
@if (isMethodLocked('sms')) {
<span class="ac-tile__cta ac-tile__cta--locked"> <mat-icon>lock</mat-icon> Locked </span>
} @else {
<span class="ac-tile__cta">
{{ sms.verified ? 'Manage' : 'Set up' }} <mat-icon>arrow_forward</mat-icon>
</span>
}
<span class="ac-type-pill ac-tile__type">Type · SMS</span>
</button>
<!-- Email -->
<button
class="ac-tile"
type="button"
[class.ac-tile--locked]="isMethodLocked('email')"
[class.ac-tile--selected]="isMethodPicked('email') || activeMethod === 'email'"
[class.ac-tile--active]="activeMethod === 'email'"
[disabled]="isMethodLocked('email')"
(click)="pickMethod('email')"
>
<div class="ac-tile__top">
<div class="ac-tile__icon ac-tile__icon--cyan"><mat-icon>mail</mat-icon></div>
<span
class="ac-tile__radio"
[class.is-on]="isMethodPicked('email') || activeMethod === 'email'"
[class.is-locked]="isMethodLocked('email')"
[attr.aria-checked]="isMethodPicked('email') || activeMethod === 'email'"
role="radio"
>
@if (isMethodLocked('email')) {
<mat-icon>lock</mat-icon>
} @else if (isMethodPicked('email') || activeMethod === 'email') {
<mat-icon>check</mat-icon>
}
</span>
</div>
<h4 class="ac-tile__title">Email OTP</h4>
<p class="ac-tile__desc">Receive a one-time code on your verified email address.</p>
@if (isMethodLocked('email')) {
<span class="ac-tile__cta ac-tile__cta--locked"> <mat-icon>lock</mat-icon> Locked </span>
} @else {
<span class="ac-tile__cta">
{{ email.verified ? 'Manage' : 'Set up' }} <mat-icon>arrow_forward</mat-icon>
</span>
}
<span class="ac-type-pill ac-tile__type">Type · EMAIL</span>
</button>
<!-- TOTP -->
<button
class="ac-tile"
type="button"
[class.ac-tile--locked]="isMethodLocked('totp')"
[class.ac-tile--selected]="isMethodPicked('totp') || activeMethod === 'totp'"
[class.ac-tile--active]="activeMethod === 'totp'"
[disabled]="isMethodLocked('totp')"
(click)="pickMethod('totp')"
>
<div class="ac-tile__top">
<div class="ac-tile__icon ac-tile__icon--violet"><mat-icon>qr_code_2</mat-icon></div>
<span
class="ac-tile__radio"
[class.is-on]="isMethodPicked('totp') || activeMethod === 'totp'"
[class.is-locked]="isMethodLocked('totp')"
[attr.aria-checked]="isMethodPicked('totp') || activeMethod === 'totp'"
role="radio"
>
@if (isMethodLocked('totp')) {
<mat-icon>lock</mat-icon>
} @else if (isMethodPicked('totp') || activeMethod === 'totp') {
<mat-icon>check</mat-icon>
}
</span>
</div>
<h4 class="ac-tile__title">Authenticator App</h4>
<p class="ac-tile__desc">Use Google Authenticator, Microsoft Authenticator, or Authy.</p>
@if (isMethodLocked('totp')) {
<span class="ac-tile__cta ac-tile__cta--locked"> <mat-icon>lock</mat-icon> Locked </span>
} @else {
<span class="ac-tile__cta">
{{ totp.verified ? 'Manage' : 'Set up' }} <mat-icon>arrow_forward</mat-icon>
</span>
}
<span class="ac-type-pill ac-tile__type">Type · TOTP</span>
</button>
<!-- Hardware -->
<button
class="ac-tile"
type="button"
[class.ac-tile--locked]="isMethodLocked('hardware')"
[class.ac-tile--selected]="isMethodPicked('hardware') || activeMethod === 'hardware'"
[class.ac-tile--active]="activeMethod === 'hardware'"
[disabled]="isMethodLocked('hardware')"
(click)="pickMethod('hardware')"
>
<div class="ac-tile__top">
<div class="ac-tile__icon ac-tile__icon--amber"><mat-icon>memory</mat-icon></div>
<span
class="ac-tile__radio"
[class.is-on]="isMethodPicked('hardware') || activeMethod === 'hardware'"
[class.is-locked]="isMethodLocked('hardware')"
[attr.aria-checked]="isMethodPicked('hardware') || activeMethod === 'hardware'"
role="radio"
>
@if (isMethodLocked('hardware')) {
<mat-icon>lock</mat-icon>
} @else if (isMethodPicked('hardware') || activeMethod === 'hardware') {
<mat-icon>check</mat-icon>
}
</span>
</div>
<h4 class="ac-tile__title">Hardware Token</h4>
<p class="ac-tile__desc">Smart Card, Digital Signature (DSC) or Biometric (FIDO2 / WebAuthn).</p>
@if (isMethodLocked('hardware')) {
<span class="ac-tile__cta ac-tile__cta--locked"> <mat-icon>lock</mat-icon> Locked </span>
} @else {
<span class="ac-tile__cta">Manage devices <mat-icon>arrow_forward</mat-icon></span>
}
<span class="ac-type-pill ac-tile__type">Type · TOKEN</span>
</button>
</div>
<div class="ac-picker-cta">
<div class="ac-picker-cta__hint">
@if (pickedMethod) {
<mat-icon>check_circle</mat-icon>
<span>
<strong>{{ methodLabel(pickedMethod) }}</strong> selected. Continue to set it up.
</span>
} @else {
<mat-icon>radio_button_unchecked</mat-icon>
<span>Pick a method above to continue.</span>
}
</div>
<button
mat-flat-button
color="primary"
class="ac-picker-cta__btn"
[disabled]="!pickedMethod"
(click)="continueWithPicked()"
>
@if (pickedMethod && pickedMethod === activeMethod) {
Manage {{ methodLabel(pickedMethod) }}
} @else if (pickedMethod) {
Set up {{ methodLabel(pickedMethod) }}
} @else {
Set up
}
<mat-icon>arrow_forward</mat-icon>
</button>
</div>
</section>
}
<!-- ════════════ SETUP PANEL (after selection) ════════════ -->
@if (selectedMethod) {
<section class="ac-panel">
<button type="button" class="ac-back" (click)="clearSelection()">
<span class="ac-back__icon"><mat-icon>arrow_back</mat-icon></span>
<span class="ac-back__text">
<span class="ac-back__sub">Authentication</span>
<span class="ac-back__main">All methods</span>
</span>
</button>
<!-- ───────── SMS OTP ───────── -->
@if (selectedMethod === 'sms') {
<header class="ac-panel__head">
<div class="ac-panel__icon ac-tile__icon--blue"><mat-icon>smartphone</mat-icon></div>
<div class="ac-panel__title">
<h3>SMS OTP</h3>
<span class="ac-type-pill">Type · SMS</span>
</div>
<span class="ac-state-pill" [class.ok]="sms.verified" [class.warn]="!sms.verified">
<mat-icon>{{ sms.verified ? 'check_circle' : 'cancel' }}</mat-icon>
{{ sms.verified ? 'Verified' : 'Not Verified' }}
</span>
</header>
<p class="ac-panel__sub">Verify a 10-digit Indian mobile number with a one-time passcode.</p>
<!-- Progress stepper -->
<ol class="ac-stepper">
<li
[class.is-active]="!sms.destinationConfirmed && !sms.verified"
[class.is-done]="sms.destinationConfirmed || sms.verified"
>
<span class="ac-stepper__num">1</span>
<div class="ac-stepper__text">
<strong>Enter mobile</strong>
<em>10-digit Indian number</em>
</div>
</li>
<li [class.is-active]="sms.destinationConfirmed && !sms.verified" [class.is-done]="sms.verified">
<span class="ac-stepper__num">2</span>
<div class="ac-stepper__text">
<strong>Verify OTP</strong>
<em>6-digit code</em>
</div>
</li>
<li [class.is-done]="sms.verified">
<span class="ac-stepper__num">3</span>
<div class="ac-stepper__text">
<strong>Activated</strong>
<em>Ready to use</em>
</div>
</li>
</ol>
<div class="ac-panel__body">
@if (sms.busy) {
<mat-progress-bar mode="indeterminate" class="ac-progress"></mat-progress-bar>
}
@if (!sms.destinationConfirmed) {
<div class="ac-otp-layout">
<div class="ac-otp-form">
<h5 class="ac-otp-form__title">Step 1 · Mobile number</h5>
<p class="ac-otp-form__hint">We'll send a 6-digit code to this number. Standard SMS rates may apply.</p>
@if (sms.destination && profileLoaded) {
<div class="ac-prefill-chip">
<mat-icon>person_pin</mat-icon>
Prefilled from your profile. Update your profile to change it.
</div>
}
<div class="ac-input-row">
<mat-form-field appearance="outline" class="ac-ff ac-ff--readonly">
<mat-label>Mobile number</mat-label>
<span matPrefix class="ac-prefix">+91&nbsp;</span>
<input matInput readonly [value]="sms.destination" placeholder="No mobile on profile" />
<mat-icon matSuffix class="ac-readonly-lock" matTooltip="From your profile">lock</mat-icon>
@if (sms.errorMsg) {
<mat-error>{{ sms.errorMsg }}</mat-error>
}
</mat-form-field>
<button
mat-flat-button
color="primary"
class="ac-input-row__btn"
[disabled]="sms.busy || !sms.destination"
(click)="sendOtp(sms)"
>
<mat-icon>send</mat-icon> {{ sms.busy ? 'Sending…' : 'Send OTP' }}
</button>
</div>
</div>
<aside class="ac-aside">
<div class="ac-aside__head"><mat-icon>info</mat-icon> What to expect</div>
<ul>
<li><mat-icon>schedule</mat-icon> OTP arrives within 30 seconds</li>
<li><mat-icon>verified</mat-icon> Code is valid for 5 minutes</li>
<li><mat-icon>autorenew</mat-icon> Resend available after 30 seconds</li>
<li><mat-icon>shield</mat-icon> Your number is encrypted at rest</li>
</ul>
</aside>
</div>
} @else if (!sms.verified) {
<div class="ac-otp-layout">
<div class="ac-otp-form">
<h5 class="ac-otp-form__title">Step 2 · Enter OTP</h5>
<div class="ac-otp-target">
<mat-icon>smartphone</mat-icon>
Code sent to <strong>+91 {{ sms.destination }}</strong>
</div>
<div class="ac-input-row">
<mat-form-field appearance="outline" class="ac-ff ac-ff--otp">
<mat-label>6-digit OTP</mat-label>
<input
id="ac-sms-otp-input"
matInput
[(ngModel)]="sms.otp"
maxlength="6"
inputmode="numeric"
placeholder="• • • • • •"
(input)="sms.errorMsg = ''"
(keypress)="$event.charCode >= 48 && $event.charCode <= 57 ? true : $event.preventDefault()"
/>
<mat-hint>
@if (sms.resendIn > 0) {
Resend in {{ sms.resendIn }}s
} @else {
<a class="ac-link" (click)="resendOtp(sms)">Resend OTP</a>
}
</mat-hint>
</mat-form-field>
<button
mat-flat-button
color="primary"
class="ac-input-row__btn"
[disabled]="sms.busy"
(click)="verifyOtp(sms)"
>
<mat-icon>verified</mat-icon> {{ sms.busy ? 'Verifying…' : 'Verify' }}
</button>
</div>
@if (sms.errorMsg) {
<div class="ac-inline-error">
<mat-icon>error_outline</mat-icon>
<span>{{ sms.errorMsg }}</span>
</div>
}
</div>
<aside class="ac-aside">
<div class="ac-aside__head"><mat-icon>help_outline</mat-icon> Didn't receive?</div>
<ul>
<li><mat-icon>signal_cellular_alt</mat-icon> Check signal & DND settings</li>
<li><mat-icon>autorenew</mat-icon> Tap "Resend OTP" after the timer</li>
<li><mat-icon>edit</mat-icon> Use "Change" to fix the number</li>
<li><mat-icon>support_agent</mat-icon> Contact support if it persists</li>
</ul>
</aside>
</div>
} @else {
<div class="ac-otp-done">
<mat-icon>check_circle</mat-icon>
<div>
<strong>SMS OTP active</strong>
<span
>OTP will be sent to <code>+91 {{ sms.destination }}</code> on every sign-in.</span
>
</div>
<button mat-stroked-button color="warn" (click)="disableOtpChannel(sms)">
<mat-icon>power_settings_new</mat-icon> Disable
</button>
</div>
}
</div>
}
<!-- ───────── Email OTP ───────── -->
@if (selectedMethod === 'email') {
<header class="ac-panel__head">
<div class="ac-panel__icon ac-tile__icon--cyan"><mat-icon>mail</mat-icon></div>
<div class="ac-panel__title">
<h3>Email OTP</h3>
<span class="ac-type-pill">Type · EMAIL</span>
</div>
<span class="ac-state-pill" [class.ok]="email.verified" [class.warn]="!email.verified">
<mat-icon>{{ email.verified ? 'check_circle' : 'cancel' }}</mat-icon>
{{ email.verified ? 'Verified' : 'Not Verified' }}
</span>
</header>
<p class="ac-panel__sub">Verify your email address with a one-time passcode.</p>
<!-- Progress stepper -->
<ol class="ac-stepper">
<li
[class.is-active]="!email.destinationConfirmed && !email.verified"
[class.is-done]="email.destinationConfirmed || email.verified"
>
<span class="ac-stepper__num">1</span>
<div class="ac-stepper__text">
<strong>Enter email</strong>
<em>Valid email address</em>
</div>
</li>
<li [class.is-active]="email.destinationConfirmed && !email.verified" [class.is-done]="email.verified">
<span class="ac-stepper__num">2</span>
<div class="ac-stepper__text">
<strong>Verify OTP</strong>
<em>6-digit code</em>
</div>
</li>
<li [class.is-done]="email.verified">
<span class="ac-stepper__num">3</span>
<div class="ac-stepper__text">
<strong>Activated</strong>
<em>Ready to use</em>
</div>
</li>
</ol>
<div class="ac-panel__body">
@if (email.busy) {
<mat-progress-bar mode="indeterminate" class="ac-progress"></mat-progress-bar>
}
@if (!email.destinationConfirmed) {
<div class="ac-otp-layout">
<div class="ac-otp-form">
<h5 class="ac-otp-form__title">Step 1 · Email address</h5>
<p class="ac-otp-form__hint">
We'll send a 6-digit code to this address. Check your spam folder if not received.
</p>
@if (email.destination && profileLoaded) {
<div class="ac-prefill-chip">
<mat-icon>person_pin</mat-icon>
Prefilled from your profile. Update your profile to change it.
</div>
}
<div class="ac-input-row">
<mat-form-field appearance="outline" class="ac-ff ac-ff--readonly">
<mat-label>Email address</mat-label>
<span matPrefix class="ac-prefix"><mat-icon>alternate_email</mat-icon></span>
<input
matInput
type="email"
readonly
[value]="email.destination"
placeholder="No email on profile"
/>
<mat-icon matSuffix class="ac-readonly-lock" matTooltip="From your profile">lock</mat-icon>
@if (email.errorMsg) {
<mat-error>{{ email.errorMsg }}</mat-error>
}
</mat-form-field>
<button
mat-flat-button
color="primary"
class="ac-input-row__btn"
[disabled]="email.busy || !email.destination"
(click)="sendOtp(email)"
>
<mat-icon>send</mat-icon> {{ email.busy ? 'Sending…' : 'Send OTP' }}
</button>
</div>
</div>
<aside class="ac-aside">
<div class="ac-aside__head"><mat-icon>info</mat-icon> What to expect</div>
<ul>
<li><mat-icon>schedule</mat-icon> Email arrives within 30 seconds</li>
<li><mat-icon>verified</mat-icon> Code is valid for 10 minutes</li>
<li><mat-icon>folder</mat-icon> Check spam / promotions folder</li>
<li><mat-icon>shield</mat-icon> Your address is encrypted at rest</li>
</ul>
</aside>
</div>
} @else if (!email.verified) {
<div class="ac-otp-layout">
<div class="ac-otp-form">
<h5 class="ac-otp-form__title">Step 2 · Enter OTP</h5>
<div class="ac-otp-target">
<mat-icon>mail</mat-icon>
Code sent to <strong>{{ email.destination }}</strong>
</div>
<div class="ac-input-row">
<mat-form-field appearance="outline" class="ac-ff ac-ff--otp">
<mat-label>6-digit OTP</mat-label>
<input
id="ac-email-otp-input"
matInput
[(ngModel)]="email.otp"
maxlength="6"
inputmode="numeric"
placeholder="• • • • • •"
(input)="email.errorMsg = ''"
(keypress)="$event.charCode >= 48 && $event.charCode <= 57 ? true : $event.preventDefault()"
/>
<mat-hint>
@if (email.resendIn > 0) {
Resend in {{ email.resendIn }}s
} @else {
<a class="ac-link" (click)="resendOtp(email)">Resend OTP</a>
}
</mat-hint>
</mat-form-field>
<button
mat-flat-button
color="primary"
class="ac-input-row__btn"
[disabled]="email.busy"
(click)="verifyOtp(email)"
>
<mat-icon>verified</mat-icon> {{ email.busy ? 'Verifying…' : 'Verify' }}
</button>
</div>
@if (email.errorMsg) {
<div class="ac-inline-error">
<mat-icon>error_outline</mat-icon>
<span>{{ email.errorMsg }}</span>
</div>
}
</div>
<aside class="ac-aside">
<div class="ac-aside__head"><mat-icon>help_outline</mat-icon> Didn't receive?</div>
<ul>
<li><mat-icon>folder</mat-icon> Check spam / promotions</li>
<li><mat-icon>autorenew</mat-icon> Tap "Resend OTP" after the timer</li>
<li><mat-icon>edit</mat-icon> Use "Change" to fix the address</li>
<li><mat-icon>support_agent</mat-icon> Contact support if it persists</li>
</ul>
</aside>
</div>
} @else {
<div class="ac-otp-done">
<mat-icon>check_circle</mat-icon>
<div>
<strong>Email OTP active</strong>
<span
>OTP will be sent to <code>{{ email.destination }}</code> on every sign-in.</span
>
</div>
<button mat-stroked-button color="warn" (click)="disableOtpChannel(email)">
<mat-icon>power_settings_new</mat-icon> Disable
</button>
</div>
}
</div>
}
<!-- ───────── TOTP ───────── -->
@if (selectedMethod === 'totp') {
<header class="ac-panel__head">
<div class="ac-panel__icon ac-tile__icon--violet"><mat-icon>qr_code_2</mat-icon></div>
<div class="ac-panel__title">
<h3>Authenticator App</h3>
<span class="ac-type-pill">Type · TOTP</span>
</div>
<span class="ac-state-pill" [class.ok]="totp.verified" [class.warn]="!totp.verified">
<mat-icon>{{ totp.verified ? 'check_circle' : 'cancel' }}</mat-icon>
{{ totp.verified ? 'Registered' : 'Not Registered' }}
</span>
</header>
<p class="ac-panel__sub">
Generate a key, scan it in your authenticator app, then enter the 6-digit code to register this device.
</p>
<div class="ac-panel__body">
@if (!totp.secret && !totp.verified) {
<div class="ac-totp-empty">
<mat-icon>qr_code_scanner</mat-icon>
<div>
<strong>No authenticator linked yet</strong>
<p>Generate a setup key, scan it in your TOTP app, then verify the 6-digit code shown there.</p>
</div>
<button mat-flat-button color="primary" (click)="generateTotpSecret()">
<mat-icon>auto_awesome</mat-icon> Generate setup key
</button>
</div>
} @else if (totp.secret && !totp.verified) {
<div class="ac-totp-setup">
<div class="ac-totp-setup__qr">
<div class="ac-qr-frame">
<div class="ac-qr-grid">
@for (row of totpQrCells; track $index) {
<div class="ac-qr-row">
@for (cell of row; track $index) {
<span class="ac-qr-cell" [class.is-on]="cell"></span>
}
</div>
}
</div>
</div>
<p class="ac-qr-caption">Scan in your authenticator app</p>
</div>
<div class="ac-totp-setup__details">
<div class="ac-totp-step-list">
<div class="ac-totp-step">
<span class="ac-totp-step__num">1</span>
<div>Open <strong>Google Authenticator</strong> (or any TOTP app) and tap “Add account”.</div>
</div>
<div class="ac-totp-step">
<span class="ac-totp-step__num">2</span>
<div>
Scan the code on the left, or enter the secret manually.
<div class="ac-totp-secret">
<code>{{ totp.secret }}</code>
<button mat-icon-button matTooltip="Copy" (click)="copyTotpSecret()">
<mat-icon>content_copy</mat-icon>
</button>
</div>
</div>
</div>
<div class="ac-totp-step">
<span class="ac-totp-step__num">3</span>
<div>Enter the 6-digit code shown by your app to verify and register this device.</div>
</div>
</div>
<mat-form-field appearance="outline" class="ac-ff ac-ff--otp">
<mat-label>Authenticator code</mat-label>
<input
matInput
[(ngModel)]="totp.otp"
maxlength="6"
inputmode="numeric"
placeholder="• • • • • •"
(input)="totp.errorMsg = ''"
(keypress)="$event.charCode >= 48 && $event.charCode <= 57 ? true : $event.preventDefault()"
/>
@if (totp.errorMsg) {
<mat-error>{{ totp.errorMsg }}</mat-error>
}
</mat-form-field>
<div class="ac-totp-actions">
<button mat-flat-button color="primary" [disabled]="totp.busy" (click)="verifyTotp()">
<mat-icon>verified</mat-icon> {{ totp.busy ? 'Verifying…' : 'Verify & Register' }}
</button>
<button mat-stroked-button color="warn" (click)="disableTotp()">
<mat-icon>refresh</mat-icon> Discard & restart
</button>
</div>
</div>
</div>
} @else {
<div class="ac-otp-done">
<mat-icon>check_circle</mat-icon>
<div>
<strong>Authenticator app registered</strong>
<span>Sign-in will require a 6-digit TOTP code from your app.</span>
</div>
<button mat-stroked-button color="warn" (click)="disableTotp()">
<mat-icon>power_settings_new</mat-icon> Unlink
</button>
</div>
}
</div>
}
<!-- ───────── Hardware Token (redesigned) ───────── -->
@if (selectedMethod === 'hardware') {
<header class="ac-panel__head">
<div class="ac-panel__icon ac-tile__icon--amber"><mat-icon>memory</mat-icon></div>
<div class="ac-panel__title">
<h3>Hardware Token</h3>
<span class="ac-type-pill">Type · TOKEN</span>
</div>
<span
class="ac-state-pill"
[class.ok]="methodStatus('hardware').ok"
[class.warn]="!methodStatus('hardware').ok"
>
<mat-icon>{{ methodStatus('hardware').ok ? 'check_circle' : 'cancel' }}</mat-icon>
{{ methodStatus('hardware').label }}
</span>
</header>
<p class="ac-panel__sub">Choose a device kind, then connect & register it.</p>
<div class="ac-panel__body">
@if (!selectedHwKind) {
<!-- Hardware sub-picker (compact rows with status, connect button) -->
<div class="ac-hw-list">
@for (item of hardware; track item.kind) {
<div class="ac-hw-row" [class.is-registered]="item.registered">
<div class="ac-hw-row__icon">
<mat-icon>{{ item.icon }}</mat-icon>
</div>
<div class="ac-hw-row__main">
<div class="ac-hw-row__title">
{{ item.title }}
<span class="ac-state-pill" [class.ok]="item.registered" [class.warn]="!item.registered">
<mat-icon>{{ item.registered ? 'check_circle' : 'radio_button_unchecked' }}</mat-icon>
{{ item.registered ? 'Registered' : 'Not Registered' }}
</span>
</div>
<p class="ac-hw-row__desc">{{ item.description }}</p>
@if (item.registered) {
<div class="ac-hw-row__meta">
<span
><mat-icon>tag</mat-icon> <code>{{ item.serial }}</code></span
>
<span><mat-icon>schedule</mat-icon> {{ item.registeredAt }}</span>
</div>
}
</div>
<div class="ac-hw-row__cta">
<button mat-flat-button color="primary" (click)="selectHwKind(item.kind)">
<mat-icon>{{ item.registered ? 'settings' : 'add' }}</mat-icon>
{{ item.registered ? 'Manage' : 'Register' }}
</button>
</div>
</div>
}
</div>
} @else if (currentHwItem) {
<!-- Hardware device registration view -->
<button type="button" class="ac-back ac-back--inner" (click)="clearHwKind()">
<span class="ac-back__icon"><mat-icon>arrow_back</mat-icon></span>
<span class="ac-back__text">
<span class="ac-back__sub">Hardware Token</span>
<span class="ac-back__main">All devices</span>
</span>
</button>
<div class="ac-hw-stage" [class.is-busy]="currentHwItem.busy" [class.is-done]="currentHwItem.registered">
<div class="ac-hw-device">
<div class="ac-hw-device__icon">
<mat-icon>{{ currentHwItem.icon }}</mat-icon>
@if (currentHwItem.busy) {
<span class="ac-hw-device__pulse"></span>
}
@if (currentHwItem.registered) {
<span class="ac-hw-device__check"><mat-icon>check</mat-icon></span>
}
</div>
<div class="ac-hw-device__connectors"><span></span><span></span><span></span></div>
</div>
<div class="ac-hw-info">
<h4>
{{ currentHwItem.title }}
<span
class="ac-state-pill"
[class.ok]="currentHwItem.registered"
[class.warn]="!currentHwItem.registered"
>
<mat-icon>{{ currentHwItem.registered ? 'check_circle' : 'cancel' }}</mat-icon>
{{ currentHwItem.registered ? 'Registered' : currentHwItem.busy ? 'Detecting…' : 'Not Registered' }}
</span>
</h4>
<p>{{ currentHwItem.description }}</p>
<ol class="ac-hw-steps">
<li [class.is-done]="currentHwItem.busy || currentHwItem.registered">
<mat-icon>{{
currentHwItem.busy || currentHwItem.registered ? 'check_circle' : 'circle'
}}</mat-icon>
Connect / insert the device
</li>
<li [class.is-done]="currentHwItem.registered" [class.is-active]="currentHwItem.busy">
<mat-icon>{{
currentHwItem.registered ? 'check_circle' : currentHwItem.busy ? 'autorenew' : 'circle'
}}</mat-icon>
Detect & read serial number
</li>
<li [class.is-done]="currentHwItem.registered">
<mat-icon>{{ currentHwItem.registered ? 'check_circle' : 'circle' }}</mat-icon>
Bind device to <strong>{{ username }}</strong>
</li>
</ol>
@if (currentHwItem.registered) {
<div class="ac-hw-info__detail">
<div>
<span class="ac-hw-info__label">Serial</span>
<code>{{ currentHwItem.serial }}</code>
</div>
<div>
<span class="ac-hw-info__label">Registered on</span>
<span>{{ currentHwItem.registeredAt }}</span>
</div>
</div>
}
<div class="ac-hw-info__actions">
@if (!currentHwItem.registered) {
<button
mat-flat-button
color="primary"
[disabled]="currentHwItem.busy"
(click)="registerHardware(currentHwItem)"
>
<mat-icon>{{ currentHwItem.busy ? 'autorenew' : 'usb' }}</mat-icon>
{{ currentHwItem.busy ? 'Detecting device…' : 'Connect & Register' }}
</button>
} @else {
<button mat-stroked-button color="warn" (click)="removeHardware(currentHwItem)">
<mat-icon>link_off</mat-icon> Unregister
</button>
}
</div>
</div>
</div>
}
</div>
}
</section>
}
</div>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,823 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnDestroy, OnInit, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatButton, MatIconButton } from '@angular/material/button';
import { MatCard, MatCardContent } from '@angular/material/card';
import { MatDialog } from '@angular/material/dialog';
import { MatFormField, MatLabel, MatPrefix, MatHint, MatError } from '@angular/material/form-field';
import { MatIcon } from '@angular/material/icon';
import { MatInput } from '@angular/material/input';
import { MatProgressBar } from '@angular/material/progress-bar';
import { DeleteDialogComponent } from 'app/shared/delete-dialog/delete-dialog.component';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { AuthenticationService } from 'app/core/authentication/authentication.service';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { UsersService } from 'app/users/users.service';
import { AccessControlService, MfaFactor } from './access-control.service';
type HwSubKind = 'smart-card' | 'digital-signature' | 'biometric';
type MethodKey = 'sms' | 'email' | 'totp' | 'hardware';
interface OtpChannelState {
/** Type code as the backend will store it (SMS or EMAIL) */
typeCode: 'SMS' | 'EMAIL';
destination: string;
destinationConfirmed: boolean;
otp: string;
otpSent: boolean;
resendIn: number;
verified: boolean;
errorMsg: string;
busy: boolean;
/** otpId returned from the OTP-generate endpoint, used by verify */
otpId: number | null;
/** verifiedOn timestamp from the server (ISO string) */
verifiedOn: string;
/** Persisted factor id from m_user_mfa_factor */
factorId: number | null;
}
interface TotpState {
typeCode: 'TOTP';
secret: string;
otp: string;
verified: boolean;
errorMsg: string;
busy: boolean;
}
interface HardwareItem {
kind: HwSubKind;
title: string;
description: string;
icon: string;
buttonLabel: string;
registered: boolean;
serial: string;
registeredAt: string;
busy: boolean;
}
interface PersistedPrefs {
sms?: { destination: string; verified: boolean };
email?: { destination: string; verified: boolean };
totp?: { secret: string; verified: boolean };
hardware?: Record<HwSubKind, { registered: boolean; serial: string; registeredAt: string }>;
}
@Component({
selector: 'mifosx-access-control',
templateUrl: './access-control.component.html',
styleUrls: ['./access-control.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FormsModule,
FaIconComponent,
MatCard,
MatCardContent,
MatIcon,
MatButton,
MatIconButton,
MatFormField,
MatLabel,
MatPrefix,
MatHint,
MatError,
MatInput,
MatProgressBar
]
})
export class AccessControlComponent implements OnInit, OnDestroy {
private authenticationService = inject(AuthenticationService);
private accessControlService = inject(AccessControlService);
private usersService = inject(UsersService);
private dialog = inject(MatDialog);
/** True once the user's profile (mobile/email) has been fetched & prefilled */
profileLoaded = false;
username = '';
private storageKey = '';
/** Currently selected method to configure (null = picker view) */
selectedMethod: MethodKey | null = null;
/** Transient picker selection (radio state) before user clicks Continue. */
pickedMethod: MethodKey | null = null;
/** Sub-selection inside the hardware token flow */
selectedHwKind: HwSubKind | null = null;
/** Section 1 — SMS / Email OTP */
sms: OtpChannelState = this.makeOtpState('SMS');
email: OtpChannelState = this.makeOtpState('EMAIL');
/** Section 2 — Authenticator app (TOTP) */
totp: TotpState = {
typeCode: 'TOTP',
secret: '',
otp: '',
verified: false,
errorMsg: '',
busy: false
};
/** Section 3 — Hardware Token (Smart Card / DSC / Biometric) */
hardware: HardwareItem[] = [
{
kind: 'smart-card',
title: 'Smart Card',
description: 'Register a contact / contactless smart card to authenticate sign-in.',
icon: 'credit_card',
buttonLabel: 'Register Smart Card',
registered: false,
serial: '',
registeredAt: '',
busy: false
},
{
kind: 'digital-signature',
title: 'Digital Signature (DSC)',
description: 'Register a Class-3 DSC USB token for high-value transaction signing.',
icon: 'verified_user',
buttonLabel: 'Register DSC Token',
registered: false,
serial: '',
registeredAt: '',
busy: false
},
{
kind: 'biometric',
title: 'Biometric',
description: 'Register fingerprint / face capture (FIDO2 / WebAuthn supported devices).',
icon: 'fingerprint',
buttonLabel: 'Register Biometric',
registered: false,
serial: '',
registeredAt: '',
busy: false
}
];
private resendTimer: any = null;
/**
* Flip to true once `GET /mfa/factors` is deployed on the backend.
* Until then, hitting it triggers Spring Security's Basic challenge,
* which makes the browser pop up its native sign-in dialog.
*/
private readonly mfaFactorsApiAvailable = true;
ngOnInit(): void {
const creds = this.authenticationService.getCredentials();
this.username = creds?.username || 'guest';
this.storageKey = `mifosx-mfa-prefs:${this.username}`;
this.loadPrefs();
this.prefillFromProfile(creds);
if (this.mfaFactorsApiAvailable) {
this.hydrateFromServer();
}
// If a method is already active, pre-select its radio so the page loads
// with the user's current choice highlighted in green.
if (this.activeMethod) {
this.pickedMethod = this.activeMethod;
}
}
/**
* Seed SMS / Email destination fields from the signed-in user's profile.
* Prefer values cached on the credentials object (populated at login by
* `POST /authentication`); fall back to `GET /users/{id}` only when the
* auth response didn't include them.
*/
private prefillFromProfile(creds: any): void {
// Try the credentials object first (works if /authentication returns these)
const sessionMobile = this.pickMobile(creds);
const sessionEmail = this.pickEmail(creds);
if (sessionMobile || sessionEmail) {
this.applyContact(sessionMobile, sessionEmail);
// If only one of the two came from the session, still fetch the profile
// for the missing piece.
if (sessionMobile && sessionEmail) {
this.profileLoaded = true;
return;
}
}
if (creds?.userId == null) {
this.profileLoaded = true;
return;
}
this.usersService.getUser(String(creds.userId)).subscribe({
next: (user: any) => {
this.profileLoaded = true;
const apiMobile = this.pickMobile(user);
const apiEmail = this.pickEmail(user);
this.applyContact(apiMobile, apiEmail);
},
error: () => {
this.profileLoaded = true;
}
});
}
/** Look across the common field names Fineract returns for mobile. */
private pickMobile(src: any): string | undefined {
if (!src) return undefined;
return (
src.mobileNo ||
src.mobile_no ||
src.mobileNumber ||
src.mobile ||
src.phoneNumber ||
src.phone ||
src.contactNumber ||
src.user?.mobileNo ||
src.user?.mobile_no ||
undefined
);
}
/** Look across the common field names Fineract returns for email. */
private pickEmail(src: any): string | undefined {
if (!src) return undefined;
return src.email || src.emailAddress || src.email_address || src.user?.email || undefined;
}
/**
* Apply mobile/email to the channel state, overwriting any previous value.
* The profile is the source of truth — we never preserve a stale local
* cache once a fresh value comes back.
*/
private applyContact(mobileNo?: string, email?: string): boolean {
let applied = false;
if (mobileNo) {
// Strip all non-digits then keep the last 10 digits (handles "+91 98…", "0 98…", etc.)
const digits = String(mobileNo).replace(/\D/g, '');
this.sms.destination = digits.length >= 10 ? digits.slice(-10) : digits;
applied = true;
}
if (email) {
this.email.destination = String(email).trim();
applied = true;
}
return applied;
}
/**
* Pull persisted MFA factors from the backend so the UI shows the
* actual verified state after a page reload. The API response REPLACES
* any previous in-memory state, so a row deleted in the DB clears the
* UI on the very next load.
*/
private hydrateFromServer(): void {
this.accessControlService.listMyFactors().subscribe({
next: (factors: MfaFactor[]) => {
// Clear all factors first — server is authoritative.
this.resetAllFactors();
if (!factors?.length) return;
for (const f of factors) {
if (f.factorType === 'SMS') {
this.applyFactor(this.sms, f);
} else if (f.factorType === 'EMAIL') {
this.applyFactor(this.email, f);
} else if (f.factorType === 'TOTP' && f.verified) {
this.totp.secret = f.secret || this.totp.secret;
this.totp.verified = true;
} else if (f.factorType === 'TOKEN') {
const item = this.hardware.find((h) => h.serial === f.serial);
if (item) {
item.registered = true;
item.serial = f.serial || item.serial;
item.registeredAt = f.verifiedOn || item.registeredAt;
}
}
}
// Re-pick the active method for the radio after hydrate.
if (this.activeMethod) {
this.pickedMethod = this.activeMethod;
} else {
this.pickedMethod = null;
}
},
error: () => {
/* endpoint may not exist yet — local state is left empty */
}
});
}
/** Wipe every factor's verified / registered flags before re-applying API truth. */
private resetAllFactors(): void {
this.sms.verified = false;
this.sms.destinationConfirmed = false;
this.sms.factorId = null;
this.sms.verifiedOn = '';
this.email.verified = false;
this.email.destinationConfirmed = false;
this.email.factorId = null;
this.email.verifiedOn = '';
this.totp.verified = false;
this.totp.secret = '';
for (const h of this.hardware) {
h.registered = false;
h.serial = '';
h.registeredAt = '';
}
}
private applyFactor(channel: OtpChannelState, f: MfaFactor): void {
if (f.destination) channel.destination = f.destination;
channel.verified = !!f.verified;
channel.destinationConfirmed = !!f.verified;
channel.factorId = f.id ?? null;
channel.verifiedOn = f.verifiedOn || '';
}
ngOnDestroy(): void {
if (this.resendTimer) clearInterval(this.resendTimer);
}
// ───────────────────────────── SMS / Email OTP
/** Send OTP via /login/otp/generate (SMS) or /login/otp/email/generate */
sendOtp(channel: OtpChannelState): void {
const dest = channel.destination?.trim() || '';
if (!this.isDestinationValid(channel.typeCode, dest)) {
channel.errorMsg =
channel.typeCode === 'SMS' ? 'Enter a valid 10-digit mobile number.' : 'Enter a valid email address.';
return;
}
if (!this.username) {
channel.errorMsg = 'You must be signed in to set up MFA.';
return;
}
channel.errorMsg = '';
channel.busy = true;
const obs$ =
channel.typeCode === 'SMS'
? this.accessControlService.sendMobileOtp(this.username)
: this.accessControlService.sendEmailOtp(this.username);
obs$.subscribe({
next: (res) => {
channel.busy = false;
if (res?.success && res.otpId != null) {
channel.otpId = res.otpId;
channel.otpSent = true;
channel.destinationConfirmed = true;
channel.otp = '';
channel.resendIn = 30;
this.startResendTimer(channel);
// Move the OTP entry into view + focus the input so the user
// doesn't have to scroll down to type the code.
setTimeout(() => this.focusOtpInput(channel.typeCode), 100);
} else {
channel.errorMsg = res?.message || 'Could not send OTP. Try again.';
}
},
error: (err) => {
channel.busy = false;
channel.errorMsg = err?.error?.message || err?.error?.defaultUserMessage || 'OTP request failed.';
}
});
}
/** Scroll-to + focus the OTP entry field for the active channel. */
private focusOtpInput(typeCode: 'SMS' | 'EMAIL'): void {
const id = typeCode === 'SMS' ? 'ac-sms-otp-input' : 'ac-email-otp-input';
const el = document.getElementById(id) as HTMLInputElement | null;
if (!el) return;
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
el.focus({ preventScroll: true });
}
resendOtp(channel: OtpChannelState): void {
if (channel.resendIn > 0) return;
this.sendOtp(channel);
}
/** Verify OTP and persist the factor in m_user_mfa_factor */
verifyOtp(channel: OtpChannelState): void {
const code = (channel.otp || '').trim();
if (!/^\d{6}$/.test(code)) {
channel.errorMsg = 'Enter the 6-digit code received.';
return;
}
if (channel.otpId == null) {
channel.errorMsg = 'OTP session missing — request a new code.';
return;
}
channel.errorMsg = '';
channel.busy = true;
const verify$ =
channel.typeCode === 'SMS'
? this.accessControlService.verifyMobileOtp(channel.otpId, code)
: this.accessControlService.verifyEmailOtp(channel.otpId, code);
verify$.subscribe({
next: (res) => {
if (!res?.verified) {
channel.busy = false;
channel.errorMsg = res?.message || 'Invalid OTP.';
return;
}
const finishVerified = (saved?: MfaFactor) => {
channel.busy = false;
channel.verified = true;
channel.otpSent = false;
channel.resendIn = 0;
channel.factorId = saved?.id ?? null;
channel.verifiedOn = saved?.verifiedOn || new Date().toISOString();
if (this.resendTimer) clearInterval(this.resendTimer);
this.savePrefs();
};
if (!this.mfaFactorsApiAvailable) {
finishVerified();
return;
}
// Persist the verified factor — sets verified_on in the DB
this.accessControlService
.saveFactor({
factorType: channel.typeCode,
destination: channel.destination,
otpId: channel.otpId!
})
.subscribe({
next: (saved) => finishVerified(saved),
error: () => finishVerified() // OTP verified upstream, persistence failed — keep UX
});
},
error: (err) => {
channel.busy = false;
channel.errorMsg = err?.error?.message || err?.error?.defaultUserMessage || 'Verification failed.';
}
});
}
changeDestination(channel: OtpChannelState): void {
channel.destinationConfirmed = false;
channel.otpSent = false;
channel.otp = '';
channel.errorMsg = '';
channel.resendIn = 0;
channel.otpId = null;
}
disableOtpChannel(channel: OtpChannelState): void {
const label = channel.typeCode === 'SMS' ? 'SMS OTP' : 'Email OTP';
this.dialog
.open(DeleteDialogComponent, {
data: { deleteContext: `${label} as your MFA method` }
})
.afterClosed()
.subscribe((response: any) => {
if (!response?.delete) return;
const finish = () => {
channel.verified = false;
channel.otp = '';
channel.otpSent = false;
channel.destinationConfirmed = false;
channel.errorMsg = '';
channel.resendIn = 0;
channel.otpId = null;
channel.factorId = null;
channel.verifiedOn = '';
this.savePrefs();
};
if (this.mfaFactorsApiAvailable && channel.factorId != null) {
this.accessControlService.disableFactor(channel.factorId).subscribe({
next: () => finish(),
error: () => finish()
});
} else {
finish();
}
});
}
private isDestinationValid(type: 'SMS' | 'EMAIL', value: string): boolean {
if (!value) return false;
if (type === 'SMS') return /^\d{10}$/.test(value);
return /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(value);
}
private startResendTimer(channel: OtpChannelState): void {
if (this.resendTimer) clearInterval(this.resendTimer);
this.resendTimer = setInterval(() => {
if (channel.resendIn > 0) channel.resendIn--;
if (channel.resendIn <= 0 && this.resendTimer) {
clearInterval(this.resendTimer);
this.resendTimer = null;
}
}, 1000);
}
// ───────────────────────────── Authenticator app (TOTP)
generateTotpSecret(): void {
this.totp.secret = this.randomBase32(16);
this.totp.otp = '';
this.totp.verified = false;
this.totp.errorMsg = '';
}
/** otpauth URI shown for manual entry / QR encoding by the user's authenticator app */
get totpOtpauthUri(): string {
if (!this.totp.secret) return '';
const issuer = 'CBSNexora';
const label = `${issuer}:${this.username}`;
return `otpauth://totp/${encodeURIComponent(label)}?secret=${this.totp.secret}&issuer=${encodeURIComponent(issuer)}`;
}
/** Visual placeholder QR — deterministic 8x8 grid from the secret string */
get totpQrCells(): boolean[][] {
if (!this.totp.secret) return [];
const grid: boolean[][] = [];
let seed = 0;
for (let i = 0; i < this.totp.secret.length; i++) {
seed = (seed * 31 + this.totp.secret.charCodeAt(i)) & 0xffffffff;
}
for (let r = 0; r < 8; r++) {
const row: boolean[] = [];
for (let c = 0; c < 8; c++) {
// Three corner finder squares
const corner =
(r < 3 && c < 3) || (r < 3 && c > 4) || (r > 4 && c < 3)
? r === 0 || r === 2 || r === 5 || r === 7 || c === 0 || c === 2 || c === 5 || c === 7
: null;
if (corner !== null) {
row.push(corner);
} else {
seed = (seed * 1103515245 + 12345) & 0x7fffffff;
row.push((seed & 1) === 1);
}
}
grid.push(row);
}
return grid;
}
copyTotpSecret(): void {
if (!this.totp.secret) return;
try {
navigator.clipboard?.writeText(this.totp.secret);
} catch {
/* clipboard not available */
}
}
verifyTotp(): void {
const code = (this.totp.otp || '').trim();
if (!/^\d{6}$/.test(code)) {
this.totp.errorMsg = 'Enter the 6-digit code from your authenticator app.';
return;
}
this.totp.errorMsg = '';
this.totp.busy = true;
setTimeout(() => {
this.totp.busy = false;
this.totp.verified = true;
this.savePrefs();
}, 500);
}
disableTotp(): void {
this.totp = { typeCode: 'TOTP', secret: '', otp: '', verified: false, errorMsg: '', busy: false };
this.savePrefs();
}
private randomBase32(len: number): string {
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
let out = '';
for (let i = 0; i < len; i++) {
out += alphabet.charAt(Math.floor(Math.random() * alphabet.length));
}
return out;
}
// ───────────────────────────── Hardware Token
/** Mock device handshake — generates a synthetic serial and marks the item registered */
registerHardware(item: HardwareItem): void {
item.busy = true;
setTimeout(() => {
item.busy = false;
item.registered = true;
item.serial = this.randomSerial(item.kind);
item.registeredAt = new Date().toLocaleString();
this.savePrefs();
}, 700);
}
removeHardware(item: HardwareItem): void {
item.registered = false;
item.serial = '';
item.registeredAt = '';
this.savePrefs();
}
private randomSerial(kind: HwSubKind): string {
const prefix = kind === 'smart-card' ? 'SC' : kind === 'digital-signature' ? 'DSC' : 'BIO';
let body = '';
for (let i = 0; i < 10; i++) body += Math.floor(Math.random() * 10).toString();
return `${prefix}-${body}`;
}
// ───────────────────────────── Aggregate state
/** True when at least one method (any type) is verified/registered */
get anyMethodActive(): boolean {
return this.sms.verified || this.email.verified || this.totp.verified || this.hardware.some((h) => h.registered);
}
/** Returns the single currently-active MFA method, or null if none. */
get activeMethod(): MethodKey | null {
if (this.sms.verified) return 'sms';
if (this.email.verified) return 'email';
if (this.totp.verified) return 'totp';
if (this.hardware.some((h) => h.registered)) return 'hardware';
return null;
}
/** A method tile is locked when ANOTHER method is already active. */
isMethodLocked(method: MethodKey): boolean {
const active = this.activeMethod;
return active !== null && active !== method;
}
// ───────────────────────────── Selection navigation
/** First step: just mark the radio as picked without leaving the picker. */
pickMethod(method: MethodKey): void {
if (this.isMethodLocked(method)) return;
this.pickedMethod = method;
}
/** True when the picker has a current radio pick. */
isMethodPicked(method: MethodKey): boolean {
return this.pickedMethod === method;
}
/** Second step: enter the setup panel for the picked / requested method. */
selectMethod(method: MethodKey): void {
if (this.isMethodLocked(method)) return;
this.pickedMethod = method;
this.selectedMethod = method;
if (method !== 'hardware') this.selectedHwKind = null;
}
/** Continue button — opens setup for the currently picked method. */
continueWithPicked(): void {
if (!this.pickedMethod) return;
this.selectMethod(this.pickedMethod);
}
/** Friendly label for the picked-method title (used in the Continue button). */
methodLabel(method: MethodKey): string {
switch (method) {
case 'sms':
return 'SMS OTP';
case 'email':
return 'Email OTP';
case 'totp':
return 'Authenticator App';
case 'hardware':
return 'Hardware Token';
}
}
clearSelection(): void {
this.selectedMethod = null;
this.pickedMethod = null;
this.selectedHwKind = null;
}
selectHwKind(kind: HwSubKind): void {
this.selectedHwKind = kind;
}
clearHwKind(): void {
this.selectedHwKind = null;
}
/** Convenience: status text for a method tile */
methodStatus(method: MethodKey): { label: string; ok: boolean } {
switch (method) {
case 'sms':
return { label: this.sms.verified ? 'Verified' : 'Not configured', ok: this.sms.verified };
case 'email':
return { label: this.email.verified ? 'Verified' : 'Not configured', ok: this.email.verified };
case 'totp':
return { label: this.totp.verified ? 'Registered' : 'Not configured', ok: this.totp.verified };
case 'hardware': {
const count = this.hardware.filter((h) => h.registered).length;
return { label: count > 0 ? `${count} device${count === 1 ? '' : 's'}` : 'Not configured', ok: count > 0 };
}
}
}
/** The active hardware sub-item (used when configuring) */
get currentHwItem(): HardwareItem | null {
if (!this.selectedHwKind) return null;
return this.hardware.find((h) => h.kind === this.selectedHwKind) || null;
}
// ───────────────────────────── Persistence (localStorage stub)
private makeOtpState(typeCode: 'SMS' | 'EMAIL'): OtpChannelState {
return {
typeCode,
destination: '',
destinationConfirmed: false,
otp: '',
otpSent: false,
resendIn: 0,
verified: false,
errorMsg: '',
busy: false,
otpId: null,
verifiedOn: '',
factorId: null
};
}
private loadPrefs(): void {
// When the backend MFA-factor API is wired up, it is the SOLE source of
// truth for verified state — never restore "verified" / "registered"
// from a local cache, otherwise stale entries survive DB cleanup or
// re-logins. We only keep the local cache as a last-resort fallback
// for offline / API-unavailable scenarios.
if (this.mfaFactorsApiAvailable) {
try {
window.localStorage.removeItem(this.storageKey);
} catch {
/* ignore */
}
return;
}
try {
const raw = window.localStorage.getItem(this.storageKey);
if (!raw) return;
const parsed = JSON.parse(raw) as PersistedPrefs;
if (parsed.sms) {
this.sms.verified = !!parsed.sms.verified;
this.sms.destinationConfirmed = this.sms.verified;
}
if (parsed.email) {
this.email.verified = !!parsed.email.verified;
this.email.destinationConfirmed = this.email.verified;
}
if (parsed.totp) {
this.totp.secret = parsed.totp.secret || '';
this.totp.verified = !!parsed.totp.verified;
}
if (parsed.hardware) {
for (const h of this.hardware) {
const stored = parsed.hardware[h.kind];
if (stored) {
h.registered = !!stored.registered;
h.serial = stored.serial || '';
h.registeredAt = stored.registeredAt || '';
}
}
}
} catch {
/* ignore */
}
}
private savePrefs(): void {
// When the backend MFA-factor API is the source of truth, we don't
// mirror it to localStorage at all — every page load re-hydrates from
// /mfa/factors so a deleted DB row cannot resurrect from cache.
if (this.mfaFactorsApiAvailable) {
try {
window.localStorage.removeItem(this.storageKey);
} catch {
/* ignore */
}
return;
}
const payload: PersistedPrefs = {
sms: { destination: this.sms.destination, verified: this.sms.verified },
email: { destination: this.email.destination, verified: this.email.verified },
totp: { secret: this.totp.secret, verified: this.totp.verified },
hardware: this.hardware.reduce(
(acc, h) => {
acc[h.kind] = { registered: h.registered, serial: h.serial, registeredAt: h.registeredAt };
return acc;
},
{} as PersistedPrefs['hardware']
)
};
try {
window.localStorage.setItem(this.storageKey, JSON.stringify(payload));
} catch {
/* ignore */
}
}
}
@@ -0,0 +1,22 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { NgModule } from '@angular/core';
import { SharedModule } from '../shared/shared.module';
import { AccessControlRoutingModule } from './access-control-routing.module';
import { AccessControlComponent } from './access-control.component';
@NgModule({
imports: [
SharedModule,
AccessControlRoutingModule,
AccessControlComponent
]
})
export class AccessControlModule {}
@@ -0,0 +1,97 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpHeaders } from '@angular/common/http';
import { Observable } from 'rxjs';
export interface OtpGenerateResponse {
success: boolean;
otpId?: number;
otpReference?: string;
message?: string;
}
export interface OtpVerifyResponse {
verified: boolean;
message?: string;
}
export type MfaFactorType = 'SMS' | 'EMAIL' | 'TOTP' | 'TOKEN';
export interface MfaFactor {
id?: number;
factorType: MfaFactorType;
destination?: string;
secret?: string;
serial?: string;
verified: boolean;
verifiedOn?: string;
otpLogId?: number;
}
/**
* Wraps the login-OTP endpoints (LincPay SMS + SMTP email) plus the
* MFA-factor persistence endpoints used by Access Control.
*/
@Injectable({ providedIn: 'root' })
export class AccessControlService {
private http = inject(HttpClient);
// ───── Mobile OTP (LincPay) ─────
sendMobileOtp(username: string): Observable<OtpGenerateResponse> {
return this.http.post<OtpGenerateResponse>('/login/otp/generate', { username });
}
verifyMobileOtp(otpId: number, otp: string): Observable<OtpVerifyResponse> {
return this.http.post<OtpVerifyResponse>('/login/otp/verify', { otpId, otp });
}
// ───── Email OTP (SMTP) ─────
sendEmailOtp(username: string): Observable<OtpGenerateResponse> {
return this.http.post<OtpGenerateResponse>('/login/otp/email/generate', { username });
}
verifyEmailOtp(otpId: number, otp: string): Observable<OtpVerifyResponse> {
return this.http.post<OtpVerifyResponse>('/login/otp/email/verify', { otpId, otp });
}
// ───── MFA factors persistence (NEW backend endpoints) ─────
/**
* Marker header that tells our Fineract security filter to return a plain
* 401 (no `WWW-Authenticate: Basic`) so the browser doesn't pop up its
* native Basic-Auth dialog when the endpoint isn't wired up yet.
*/
private silentHeaders = new HttpHeaders({ 'X-Requested-With': 'XMLHttpRequest' });
/** List the signed-in user's MFA factors. */
listMyFactors(): Observable<MfaFactor[]> {
return this.http.get<MfaFactor[]>('/mfa/factors', { headers: this.silentHeaders });
}
/**
* Persist a verified factor. Backend should set `verified=true`,
* `verified_on=NOW()`, and link to the matching `m_otp_log` row.
*/
saveFactor(payload: {
factorType: MfaFactorType;
destination?: string;
secret?: string;
serial?: string;
otpId?: number;
}): Observable<MfaFactor> {
return this.http.post<MfaFactor>('/mfa/factors', payload, { headers: this.silentHeaders });
}
/** Disable a factor (logical delete — sets `disabled_on`). */
disableFactor(factorId: number): Observable<{ resourceId: number }> {
return this.http.delete<{ resourceId: number }>(`/mfa/factors/${factorId}`, {
headers: this.silentHeaders
});
}
}