feat(mfa): DSC as own factor type + fix selector collisions

- access-control: register DSC enrollment under factorType='DSC' instead of
  the legacy 'TOKEN' alias, hydrate / disable paths match on the same code.
  Pill labels in the picker and Hardware Token panel updated to 'Type · DSC'.
  service.ts MfaFactorType union adds 'DSC'.
- two-factor-authentication: initialise the delivery-method form at field
  declaration instead of inside ngOnInit so Angular's first change-detection
  pass doesn't fire 'Cannot find control' before the form is built.
- Rename duplicate component selectors that were tripping NG0912 at runtime:
  mifosx-notes-tab -> mifosx-{centers,clients,current-account,groups,loans,
  savings}-notes-tab; mifosx-unassign-staff-dialog -> mifosx-{clients,groups}-
  unassign-staff-dialog. Selectors aren't referenced in templates (route /
  dialog open), so the rename has no usage-site impact.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Aditya
2026-05-12 18:07:14 +05:30
co-authored by Claude Opus 4.7
parent 1631cd3383
commit d175354f88
13 changed files with 345 additions and 60 deletions
@@ -203,7 +203,7 @@
} @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>
<span class="ac-type-pill ac-tile__type">Type · DSC</span>
</button>
</div>
@@ -396,17 +396,28 @@
</aside>
</div>
} @else {
<div class="ac-otp-done">
<mat-icon>check_circle</mat-icon>
<div class="ac-otp-done" [class.is-disabled]="!sms.mfaEnabled">
<mat-icon>{{ sms.mfaEnabled ? 'check_circle' : 'pause_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
>
<strong>{{ sms.mfaEnabled ? 'SMS OTP active' : 'SMS OTP verified · 2FA disabled' }}</strong>
<span>
Number on file: <code>+91 {{ sms.destination }}</code>
@if (sms.mfaEnabled) {
— OTP will be sent on every sign-in.
} @else {
— re-enable any time without re-verifying.
}
</span>
</div>
<button mat-stroked-button color="warn" (click)="disableOtpChannel(sms)">
<mat-icon>power_settings_new</mat-icon> Disable
</button>
@if (sms.mfaEnabled) {
<button mat-stroked-button color="warn" [disabled]="sms.busy" (click)="disableOtpChannel(sms)">
<mat-icon>power_settings_new</mat-icon> Disable
</button>
} @else {
<button mat-flat-button color="primary" [disabled]="sms.busy" (click)="enableOtpChannel(sms)">
<mat-icon>check_circle</mat-icon> Enable for 2FA
</button>
}
</div>
}
</div>
@@ -566,17 +577,28 @@
</aside>
</div>
} @else {
<div class="ac-otp-done">
<mat-icon>check_circle</mat-icon>
<div class="ac-otp-done" [class.is-disabled]="!email.mfaEnabled">
<mat-icon>{{ email.mfaEnabled ? 'check_circle' : 'pause_circle' }}</mat-icon>
<div>
<strong>Email OTP active</strong>
<span
>OTP will be sent to <code>{{ email.destination }}</code> on every sign-in.</span
>
<strong>{{ email.mfaEnabled ? 'Email OTP active' : 'Email OTP verified · 2FA disabled' }}</strong>
<span>
Address on file: <code>{{ email.destination }}</code>
@if (email.mfaEnabled) {
— OTP will be sent on every sign-in.
} @else {
— re-enable any time without re-verifying.
}
</span>
</div>
<button mat-stroked-button color="warn" (click)="disableOtpChannel(email)">
<mat-icon>power_settings_new</mat-icon> Disable
</button>
@if (email.mfaEnabled) {
<button mat-stroked-button color="warn" [disabled]="email.busy" (click)="disableOtpChannel(email)">
<mat-icon>power_settings_new</mat-icon> Disable
</button>
} @else {
<button mat-flat-button color="primary" [disabled]="email.busy" (click)="enableOtpChannel(email)">
<mat-icon>check_circle</mat-icon> Enable for 2FA
</button>
}
</div>
}
</div>
@@ -697,7 +719,7 @@
<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>
<span class="ac-type-pill">Type · DSC</span>
</div>
<span
class="ac-state-pill"
@@ -828,11 +850,30 @@
<fa-icon icon="file-signature" class="ac-hw-btn-icon"></fa-icon>
Map DSC
</button>
} @else if (currentHwItem.mfaEnabled) {
<button
mat-stroked-button
color="warn"
[disabled]="currentHwItem.busy"
(click)="disableDscMfa(currentHwItem)"
>
<fa-icon icon="ban" class="ac-hw-btn-icon"></fa-icon>
Disable for 2FA
</button>
<button
mat-stroked-button
class="ac-hw-revoke-btn"
[disabled]="currentHwItem.busy"
(click)="revokeDsc(currentHwItem)"
>
<fa-icon icon="trash" class="ac-hw-btn-icon"></fa-icon>
Revoke DSC
</button>
} @else {
<button
mat-flat-button
color="primary"
[disabled]="currentHwItem.mfaEnabled || currentHwItem.busy"
[disabled]="currentHwItem.busy"
(click)="enableDscMfa(currentHwItem)"
>
<fa-icon icon="check-circle" class="ac-hw-btn-icon"></fa-icon>
@@ -840,12 +881,12 @@
</button>
<button
mat-stroked-button
color="warn"
[disabled]="!currentHwItem.mfaEnabled || currentHwItem.busy"
(click)="disableDscMfa(currentHwItem)"
class="ac-hw-revoke-btn"
[disabled]="currentHwItem.busy"
(click)="revokeDsc(currentHwItem)"
>
<fa-icon icon="ban" class="ac-hw-btn-icon"></fa-icon>
Disable for 2FA
<fa-icon icon="trash" class="ac-hw-btn-icon"></fa-icon>
Revoke DSC
</button>
}
} @else {
@@ -959,6 +959,21 @@
font-size: 11.5px;
}
}
/* Verified but 2FA disabled — muted amber to read as "paused", not "off". */
&.is-disabled {
background: #fffbeb;
border-color: #fde68a;
color: #78350f;
> mat-icon {
color: #d97706;
}
> div code {
border-color: #fde68a;
}
}
}
.ac-ff {
@@ -23,6 +23,9 @@ import { UsersService } from 'app/users/users.service';
import { AccessControlService, MfaFactor } from './access-control.service';
import { DscService } from 'app/users/dsc/dsc.service';
import { EnrollDscDialogComponent } from 'app/users/dsc/enroll-dsc-dialog/enroll-dsc-dialog.component';
import { RevokeDscDialogComponent } from 'app/users/dsc/revoke-dsc-dialog/revoke-dsc-dialog.component';
import { SuccessDialogComponent } from 'app/shared/success-dialog/success-dialog.component';
import { ErrorDialogComponent } from 'app/shared/error-dialog/error-dialog.component';
type HwSubKind = 'smart-card' | 'digital-signature' | 'biometric';
type MethodKey = 'sms' | 'email' | 'totp' | 'hardware';
@@ -35,14 +38,26 @@ interface OtpChannelState {
otp: string;
otpSent: boolean;
resendIn: number;
/**
* True once the destination has passed an OTP challenge at least once.
* This is a permanent attestation of the destination — disabling 2FA
* does NOT reset this. To clear, the user must explicitly "Change".
*/
verified: boolean;
/**
* True when the channel is currently enforcing 2FA at sign-in.
* Independent of `verified`: a destination can be verified but
* temporarily disabled (factor row has disabled_on set), and later
* re-enabled without going through OTP again.
*/
mfaEnabled: 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 */
/** Persisted factor id from m_user_mfa_factor (null when no active factor row). */
factorId: number | null;
}
@@ -67,6 +82,10 @@ interface HardwareItem {
busy: boolean;
/** DSC-only: whether the mapped DSC is currently enabled as a 2FA factor. */
mfaEnabled?: boolean;
/** DSC-only: persisted MFA factor id when enabled (used for disable). */
factorId?: number | null;
/** DSC-only: id of the m_user_dsc row (needed for revoke). */
dscId?: number | null;
}
interface PersistedPrefs {
@@ -294,12 +313,22 @@ export class AccessControlComponent implements OnInit, OnDestroy {
} 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);
} else if (f.factorType === 'DSC') {
// Match by serial, falling back to the DSC tile if no serial match
// (DSC is the only hardware kind backed by the factor API today).
// A factor with disabled_on set is NOT currently enforcing 2FA —
// mapping (m_user_dsc cert is ACTIVE) and 2FA-enabled (factor row
// exists with disabled_on IS NULL) are two independent states.
const item =
this.hardware.find((h) => h.serial === f.serial) ||
this.hardware.find((h) => h.kind === 'digital-signature');
if (item) {
item.registered = true;
item.serial = f.serial || item.serial;
item.registeredAt = f.verifiedOn || item.registeredAt;
const isActive = !f.disabledOn && f.verified !== false;
item.mfaEnabled = isActive;
item.factorId = isActive ? (f.id ?? null) : null;
}
}
}
@@ -319,10 +348,12 @@ export class AccessControlComponent implements OnInit, OnDestroy {
/** Wipe every factor's verified / registered flags before re-applying API truth. */
private resetAllFactors(): void {
this.sms.verified = false;
this.sms.mfaEnabled = false;
this.sms.destinationConfirmed = false;
this.sms.factorId = null;
this.sms.verifiedOn = '';
this.email.verified = false;
this.email.mfaEnabled = false;
this.email.destinationConfirmed = false;
this.email.factorId = null;
this.email.verifiedOn = '';
@@ -332,14 +363,22 @@ export class AccessControlComponent implements OnInit, OnDestroy {
h.registered = false;
h.serial = '';
h.registeredAt = '';
h.mfaEnabled = false;
h.factorId = null;
h.dscId = null;
}
}
private applyFactor(channel: OtpChannelState, f: MfaFactor): void {
if (f.destination) channel.destination = f.destination;
// `verified` is the permanent attestation that this destination passed
// OTP at least once. `mfaEnabled` is the live 2FA toggle — false when
// the factor row has disabled_on set.
channel.verified = !!f.verified;
channel.destinationConfirmed = !!f.verified;
channel.factorId = f.id ?? null;
const isActive = !f.disabledOn && !!f.verified;
channel.mfaEnabled = isActive;
channel.factorId = isActive ? (f.id ?? null) : null;
channel.verifiedOn = f.verifiedOn || '';
}
@@ -434,6 +473,10 @@ export class AccessControlComponent implements OnInit, OnDestroy {
const finishVerified = (saved?: MfaFactor) => {
channel.busy = false;
channel.verified = true;
// Initial verification auto-enables the factor for 2FA — that's
// the user's intent when they walked through the OTP setup flow.
// Subsequent disable/enable is independent of `verified`.
channel.mfaEnabled = true;
channel.otpSent = false;
channel.resendIn = 0;
channel.factorId = saved?.id ?? null;
@@ -473,6 +516,41 @@ export class AccessControlComponent implements OnInit, OnDestroy {
channel.otpId = null;
}
/**
* Re-enable a SMS/Email channel whose destination was previously verified
* but is currently disabled (factor row has disabled_on set). This skips
* OTP re-verification — the attestation persists via `verified=true`.
* Saves a fresh factor row server-side and adopts its new id.
*/
enableOtpChannel(channel: OtpChannelState): void {
if (!channel.verified || !channel.destination) {
this.showError('Verify your destination with an OTP before enabling 2FA.');
return;
}
const label = channel.typeCode === 'SMS' ? 'SMS OTP' : 'Email OTP';
const finish = (saved?: MfaFactor) => {
channel.busy = false;
channel.mfaEnabled = true;
channel.factorId = saved?.id ?? channel.factorId ?? null;
this.savePrefs();
this.showSuccess(`${label} enabled for 2FA`, `${label} will be required at sign-in for ${this.username}.`);
};
if (!this.mfaFactorsApiAvailable) {
finish();
return;
}
channel.busy = true;
this.accessControlService.saveFactor({ factorType: channel.typeCode, destination: channel.destination }).subscribe({
next: (saved) => finish(saved),
error: (err) => {
channel.busy = false;
this.showError(
err?.error?.defaultUserMessage || err?.error?.message || `Could not enable ${label} as a 2FA factor.`
);
}
});
}
disableOtpChannel(channel: OtpChannelState): void {
const label = channel.typeCode === 'SMS' ? 'SMS OTP' : 'Email OTP';
const destination =
@@ -490,19 +568,17 @@ export class AccessControlComponent implements OnInit, OnDestroy {
.afterClosed()
.subscribe((response: any) => {
if (!response?.confirmed) return;
// Disable only flips the 2FA toggle. The destination + verified
// attestation stay so the user can re-enable later without
// re-running OTP. To wipe destination, the user must use "Change".
const finish = () => {
channel.verified = false;
channel.otp = '';
channel.otpSent = false;
channel.destinationConfirmed = false;
channel.errorMsg = '';
channel.resendIn = 0;
channel.otpId = null;
channel.mfaEnabled = false;
channel.factorId = null;
channel.verifiedOn = '';
channel.busy = false;
this.savePrefs();
};
if (this.mfaFactorsApiAvailable && channel.factorId != null) {
channel.busy = true;
this.accessControlService.disableFactor(channel.factorId).subscribe({
next: () => finish(),
error: () => finish()
@@ -646,6 +722,7 @@ export class AccessControlComponent implements OnInit, OnDestroy {
if (!result?.enrolled) return;
const dsc = result.dsc;
item.registered = true;
item.dscId = dsc?.id ?? null;
item.serial = dsc?.dscSerialNoFull || dsc?.dscSerialNoMasked || '';
item.registeredAt = new Date().toLocaleString();
item.mfaEnabled = false;
@@ -654,13 +731,137 @@ export class AccessControlComponent implements OnInit, OnDestroy {
}
enableDscMfa(item: HardwareItem): void {
item.mfaEnabled = true;
this.savePrefs();
if (!item.registered || !item.serial) {
this.showError('Map a DSC before enabling it as a 2FA factor.');
return;
}
const finish = (saved?: MfaFactor) => {
item.busy = false;
item.mfaEnabled = true;
item.factorId = saved?.id ?? item.factorId ?? null;
this.savePrefs();
this.showSuccess(
'DSC enabled for 2FA',
`Sign-in for ${this.username} will now require this Digital Signature token.`
);
};
if (!this.mfaFactorsApiAvailable) {
finish();
return;
}
item.busy = true;
this.accessControlService.saveFactor({ factorType: 'DSC', serial: item.serial }).subscribe({
next: (saved) => finish(saved),
error: (err) => {
item.busy = false;
this.showError(
err?.error?.defaultUserMessage || err?.error?.message || 'Could not enable DSC as a 2FA factor.'
);
}
});
}
disableDscMfa(item: HardwareItem): void {
item.mfaEnabled = false;
this.savePrefs();
this.dialog
.open(DisableConfirmDialogComponent, {
width: '440px',
autoFocus: false,
data: {
title: 'Disable DSC for 2FA',
message:
'The Digital Signature token will no longer be required at sign-in. The mapping itself stays in place — you can re-enable it any time.',
detail: item.serial ? `Currently active for serial ${item.serial}` : ''
}
})
.afterClosed()
.subscribe((response: any) => {
if (!response?.confirmed) return;
const finish = () => {
item.busy = false;
item.mfaEnabled = false;
item.factorId = null;
this.savePrefs();
this.showSuccess(
'DSC disabled for 2FA',
'The token is no longer used as a second factor. Sign-in will fall back to your password only.'
);
};
if (this.mfaFactorsApiAvailable && item.factorId != null) {
item.busy = true;
this.accessControlService.disableFactor(item.factorId).subscribe({
next: () => finish(),
error: () => finish() // keep UX consistent — local state already reflects the user's intent
});
} else {
finish();
}
});
}
/**
* Revoke a mapped DSC. Opens the existing reason-capture dialog (which calls
* /v1/dsc/{id} DELETE itself), then on success disables the MFA factor if
* it was enabled and clears the tile back to the unmapped state. Mapping
* and 2FA-enabled are independent — both must be cleared on revoke.
*/
revokeDsc(item: HardwareItem): void {
if (!item.dscId) {
this.showError('Cannot revoke — DSC certificate id is missing. Reload the page and try again.');
return;
}
// The dialog only needs id + a serial to display; build a minimal stub
// rather than fetching the full BankUserDsc just to surface a confirm.
const dscStub = {
id: item.dscId,
dscSerialNoMasked: item.serial,
dscSerialNoFull: item.serial
} as any;
const ref = this.dialog.open(RevokeDscDialogComponent, {
width: '460px',
autoFocus: false,
disableClose: true,
data: { dsc: dscStub }
});
ref.afterClosed().subscribe((result: any) => {
if (!result?.revoked) return;
const clearLocal = () => {
item.busy = false;
item.registered = false;
item.serial = '';
item.registeredAt = '';
item.mfaEnabled = false;
item.factorId = null;
item.dscId = null;
this.selectedHwKind = null;
this.savePrefs();
this.showSuccess(
'DSC revoked',
'The certificate has been revoked and unbound from your account. You can map a new DSC any time.'
);
};
// If 2FA was enabled, also disable the factor row so the next sign-in
// doesn't try to enforce a now-revoked certificate.
if (this.mfaFactorsApiAvailable && item.factorId != null) {
item.busy = true;
this.accessControlService.disableFactor(item.factorId).subscribe({
next: () => clearLocal(),
error: () => clearLocal() // revoke succeeded; factor cleanup is best-effort
});
} else {
clearLocal();
}
});
}
private showSuccess(title: string, message: string): void {
this.dialog.open(SuccessDialogComponent, {
data: { title, message, buttonLabel: 'OK' },
panelClass: 'sd-panel'
});
}
private showError(message: string): void {
this.dialog.open(ErrorDialogComponent, { data: message });
}
/**
@@ -676,6 +877,7 @@ export class AccessControlComponent implements OnInit, OnDestroy {
const active = (dscs || []).find((d) => (d?.certStatus || '').toUpperCase() === 'ACTIVE');
if (active) {
dscItem.registered = true;
dscItem.dscId = active.id;
dscItem.serial = active.dscSerialNoMasked || active.dscSerialNoFull || dscItem.serial;
dscItem.registeredAt = dscItem.registeredAt || new Date().toLocaleString();
}
@@ -695,17 +897,31 @@ export class AccessControlComponent implements OnInit, OnDestroy {
// ───────────────────────────── Aggregate state
/** True when at least one method (any type) is verified/registered */
/**
* True when at least one method is currently enforcing 2FA. For SMS/Email
* and Hardware, "active" requires the live 2FA toggle (`mfaEnabled`) — a
* verified-but-disabled destination does NOT count. TOTP is single-state.
*/
get anyMethodActive(): boolean {
return this.sms.verified || this.email.verified || this.totp.verified || this.hardware.some((h) => h.registered);
return (
(this.sms.verified && this.sms.mfaEnabled) ||
(this.email.verified && this.email.mfaEnabled) ||
this.totp.verified ||
this.hardware.some((h) => h.registered && h.mfaEnabled === true)
);
}
/** Returns the single currently-active MFA method, or null if none. */
/**
* Returns the single currently-active MFA method, or null if none.
* "Active" means the method is currently enforcing 2FA at sign-in. A
* verified-but-disabled SMS/Email channel, or a mapped-but-not-enabled
* DSC, does NOT lock out other methods.
*/
get activeMethod(): MethodKey | null {
if (this.sms.verified) return 'sms';
if (this.email.verified) return 'email';
if (this.sms.verified && this.sms.mfaEnabled) return 'sms';
if (this.email.verified && this.email.mfaEnabled) return 'email';
if (this.totp.verified) return 'totp';
if (this.hardware.some((h) => h.registered)) return 'hardware';
if (this.hardware.some((h) => h.registered && h.mfaEnabled === true)) return 'hardware';
return null;
}
@@ -803,6 +1019,7 @@ export class AccessControlComponent implements OnInit, OnDestroy {
otpSent: false,
resendIn: 0,
verified: false,
mfaEnabled: false,
errorMsg: '',
busy: false,
otpId: null,
@@ -22,7 +22,7 @@ export interface OtpVerifyResponse {
message?: string;
}
export type MfaFactorType = 'SMS' | 'EMAIL' | 'TOTP' | 'TOKEN';
export type MfaFactorType = 'SMS' | 'EMAIL' | 'TOTP' | 'TOKEN' | 'DSC';
export interface MfaFactor {
id?: number;
@@ -32,6 +32,8 @@ export interface MfaFactor {
serial?: string;
verified: boolean;
verifiedOn?: string;
/** ISO timestamp when the factor was logically disabled — when set, the factor is NOT active. */
disabledOn?: string;
otpLogId?: number;
}
@@ -15,7 +15,7 @@ import { EntityNotesTabComponent } from '../../../shared/tabs/entity-notes-tab/e
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
@Component({
selector: 'mifosx-notes-tab',
selector: 'mifosx-centers-notes-tab',
templateUrl: './notes-tab.component.html',
styleUrls: ['./notes-tab.component.scss'],
imports: [
@@ -23,7 +23,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
* Unassign staff dialog component.
*/
@Component({
selector: 'mifosx-unassign-staff-dialog',
selector: 'mifosx-clients-unassign-staff-dialog',
templateUrl: './unassign-staff-dialog.component.html',
styleUrls: ['./unassign-staff-dialog.component.scss'],
imports: [
@@ -22,7 +22,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
* Notes Tab Component
*/
@Component({
selector: 'mifosx-notes-tab',
selector: 'mifosx-clients-notes-tab',
templateUrl: './notes-tab.component.html',
styleUrls: ['./notes-tab.component.scss'],
imports: [
@@ -14,7 +14,7 @@ import { EntityNotesTabComponent } from '../../../shared/tabs/entity-notes-tab/e
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
@Component({
selector: 'mifosx-notes-tab',
selector: 'mifosx-current-account-notes-tab',
templateUrl: './notes-tab.component.html',
styleUrls: ['./notes-tab.component.scss'],
imports: [
@@ -23,7 +23,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
* Unassign staff dialog component.
*/
@Component({
selector: 'mifosx-unassign-staff-dialog',
selector: 'mifosx-groups-unassign-staff-dialog',
templateUrl: './unassign-staff-dialog.component.html',
styleUrls: ['./unassign-staff-dialog.component.scss'],
imports: [
@@ -22,7 +22,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
* Groups Notes Tab Component.
*/
@Component({
selector: 'mifosx-notes-tab',
selector: 'mifosx-groups-notes-tab',
templateUrl: './notes-tab.component.html',
styleUrls: ['./notes-tab.component.scss'],
imports: [
@@ -18,7 +18,7 @@ import { EntityNotesTabComponent } from '../../../shared/tabs/entity-notes-tab/e
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
@Component({
selector: 'mifosx-notes-tab',
selector: 'mifosx-loans-notes-tab',
templateUrl: './notes-tab.component.html',
styleUrls: ['./notes-tab.component.scss'],
imports: [
@@ -51,8 +51,18 @@ export class TwoFactorAuthenticationComponent implements OnInit, OnDestroy {
otpRequested = false;
/** Time for which OTP is valid. */
tokenValidityTime: number;
/** Two factor authentication delivery method form group. */
twoFactorAuthenticationDeliveryMethodForm: UntypedFormGroup;
/**
* Two factor authentication delivery method form group. Initialised at field declaration so
* the template can bind to it on the very first change-detection cycle — otherwise Angular's
* standalone-signal renderer fires a "Cannot find control" error on the first pass before
* ngOnInit has had a chance to build the group, even though the form works fine after init.
*/
twoFactorAuthenticationDeliveryMethodForm: UntypedFormGroup = new UntypedFormBuilder().group({
twoFactorAuthenticationDeliveryMethod: [
'',
Validators.required
]
});
/** Two factor authentication form group. */
twoFactorAuthenticationForm: UntypedFormGroup;
/** True if loading. */
@@ -81,7 +91,7 @@ export class TwoFactorAuthenticationComponent implements OnInit, OnDestroy {
* Gets the delivery methods available to receive OTP.
*/
ngOnInit() {
this.createTwoFactorAuthenticationDeliveryMethodForm();
// Delivery-method form is initialised at field declaration — no need to call create() here.
// Fetch SMS/EMAIL delivery methods and the user's ACTIVE DSCs in parallel,
// merge them into a single factor list, then auto-route if there's only one.
this.authenticationService.getDeliveryMethods().subscribe({
@@ -14,7 +14,7 @@ import { EntityNotesTabComponent } from '../../../shared/tabs/entity-notes-tab/e
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
@Component({
selector: 'mifosx-notes-tab',
selector: 'mifosx-savings-notes-tab',
templateUrl: './notes-tab.component.html',
styleUrls: ['./notes-tab.component.scss'],
imports: [