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:
co-authored by
Claude Opus 4.7
parent
1631cd3383
commit
d175354f88
@@ -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: [
|
||||
|
||||
+1
-1
@@ -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: [
|
||||
|
||||
+1
-1
@@ -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: [
|
||||
|
||||
Reference in New Issue
Block a user