- SMS OTP active
- OTP will be sent to +91 {{ sms.destination }} on every sign-in.
+ {{ sms.mfaEnabled ? 'SMS OTP active' : 'SMS OTP verified · 2FA disabled' }}
+
+ Number on file: +91 {{ sms.destination }}
+ @if (sms.mfaEnabled) {
+ — OTP will be sent on every sign-in.
+ } @else {
+ — re-enable any time without re-verifying.
+ }
+
- Email OTP active
- OTP will be sent to {{ email.destination }} on every sign-in.
+ {{ email.mfaEnabled ? 'Email OTP active' : 'Email OTP verified · 2FA disabled' }}
+
+ Address on file: {{ email.destination }}
+ @if (email.mfaEnabled) {
+ — OTP will be sent on every sign-in.
+ } @else {
+ — re-enable any time without re-verifying.
+ }
+
-
+ @if (email.mfaEnabled) {
+
+ } @else {
+
+ }
}
@@ -697,7 +719,7 @@
memory
Hardware Token
- Type · TOKEN
+ Type · DSC
Map DSC
+ } @else if (currentHwItem.mfaEnabled) {
+
+
} @else {
}
} @else {
diff --git a/src/app/access-control/access-control.component.scss b/src/app/access-control/access-control.component.scss
index d2abc00a2..2b9e1e9e6 100644
--- a/src/app/access-control/access-control.component.scss
+++ b/src/app/access-control/access-control.component.scss
@@ -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 {
diff --git a/src/app/access-control/access-control.component.ts b/src/app/access-control/access-control.component.ts
index 2964b098e..8c36fcb7a 100644
--- a/src/app/access-control/access-control.component.ts
+++ b/src/app/access-control/access-control.component.ts
@@ -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,
diff --git a/src/app/access-control/access-control.service.ts b/src/app/access-control/access-control.service.ts
index 2363964c8..10c73be32 100644
--- a/src/app/access-control/access-control.service.ts
+++ b/src/app/access-control/access-control.service.ts
@@ -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;
}
diff --git a/src/app/centers/centers-view/notes-tab/notes-tab.component.ts b/src/app/centers/centers-view/notes-tab/notes-tab.component.ts
index f3b7c1934..f8f6646c9 100644
--- a/src/app/centers/centers-view/notes-tab/notes-tab.component.ts
+++ b/src/app/centers/centers-view/notes-tab/notes-tab.component.ts
@@ -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: [
diff --git a/src/app/clients/clients-view/custom-dialogs/unassign-staff-dialog/unassign-staff-dialog.component.ts b/src/app/clients/clients-view/custom-dialogs/unassign-staff-dialog/unassign-staff-dialog.component.ts
index ff7f33a25..d7e91894f 100644
--- a/src/app/clients/clients-view/custom-dialogs/unassign-staff-dialog/unassign-staff-dialog.component.ts
+++ b/src/app/clients/clients-view/custom-dialogs/unassign-staff-dialog/unassign-staff-dialog.component.ts
@@ -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: [
diff --git a/src/app/clients/clients-view/notes-tab/notes-tab.component.ts b/src/app/clients/clients-view/notes-tab/notes-tab.component.ts
index 682d51bbd..de896b571 100644
--- a/src/app/clients/clients-view/notes-tab/notes-tab.component.ts
+++ b/src/app/clients/clients-view/notes-tab/notes-tab.component.ts
@@ -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: [
diff --git a/src/app/current-accounts/current-account-view/notes-tab/notes-tab.component.ts b/src/app/current-accounts/current-account-view/notes-tab/notes-tab.component.ts
index 1493f27d2..a8b52dc80 100644
--- a/src/app/current-accounts/current-account-view/notes-tab/notes-tab.component.ts
+++ b/src/app/current-accounts/current-account-view/notes-tab/notes-tab.component.ts
@@ -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: [
diff --git a/src/app/groups/groups-view/custom-dialogs/unassign-staff-dialog/unassign-staff-dialog.component.ts b/src/app/groups/groups-view/custom-dialogs/unassign-staff-dialog/unassign-staff-dialog.component.ts
index ff7f33a25..e3f4ac200 100644
--- a/src/app/groups/groups-view/custom-dialogs/unassign-staff-dialog/unassign-staff-dialog.component.ts
+++ b/src/app/groups/groups-view/custom-dialogs/unassign-staff-dialog/unassign-staff-dialog.component.ts
@@ -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: [
diff --git a/src/app/groups/groups-view/notes-tab/notes-tab.component.ts b/src/app/groups/groups-view/notes-tab/notes-tab.component.ts
index f46867649..8965842c9 100644
--- a/src/app/groups/groups-view/notes-tab/notes-tab.component.ts
+++ b/src/app/groups/groups-view/notes-tab/notes-tab.component.ts
@@ -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: [
diff --git a/src/app/loans/loans-view/notes-tab/notes-tab.component.ts b/src/app/loans/loans-view/notes-tab/notes-tab.component.ts
index 0905473d5..09ac10f57 100644
--- a/src/app/loans/loans-view/notes-tab/notes-tab.component.ts
+++ b/src/app/loans/loans-view/notes-tab/notes-tab.component.ts
@@ -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: [
diff --git a/src/app/login/two-factor-authentication/two-factor-authentication.component.ts b/src/app/login/two-factor-authentication/two-factor-authentication.component.ts
index 768cda2cd..c8d6dface 100644
--- a/src/app/login/two-factor-authentication/two-factor-authentication.component.ts
+++ b/src/app/login/two-factor-authentication/two-factor-authentication.component.ts
@@ -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({
diff --git a/src/app/savings/savings-account-view/notes-tab/notes-tab.component.ts b/src/app/savings/savings-account-view/notes-tab/notes-tab.component.ts
index e419b900b..3b3510bd8 100644
--- a/src/app/savings/savings-account-view/notes-tab/notes-tab.component.ts
+++ b/src/app/savings/savings-account-view/notes-tab/notes-tab.component.ts
@@ -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: [