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