feat: DSC enrollment + MFA controls, user CRUD UX, auth error hardening

- DSC module: list/enroll/revoke dialogs and dsc.service (m_bank_user_dsc API)
- Access control: DSC + MFA toggles via disable-confirm-dialog
- Two-factor auth: expanded UX (DSC + OTP flows)
- Users: create/edit form polish, success snackbar on edit, DSC widget
  retained only inside dsc/ (removed from view-user)
- Auth: X-Requested-With added globally; error handler null-safe on
  empty 500 bodies

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Aditya
2026-05-12 11:26:22 +05:30
co-authored by Claude Opus 4.7
parent 3b752dc6ae
commit 09fab88451
29 changed files with 2482 additions and 79 deletions
@@ -817,20 +817,53 @@
}
<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>
@if (currentHwItem.kind === 'digital-signature') {
@if (!currentHwItem.registered) {
<button
mat-flat-button
color="primary"
[disabled]="currentHwItem.busy"
(click)="openDscEnroll(currentHwItem)"
>
<fa-icon icon="file-signature" class="ac-hw-btn-icon"></fa-icon>
Map DSC
</button>
} @else {
<button
mat-flat-button
color="primary"
[disabled]="currentHwItem.mfaEnabled || currentHwItem.busy"
(click)="enableDscMfa(currentHwItem)"
>
<fa-icon icon="check-circle" class="ac-hw-btn-icon"></fa-icon>
Enable for 2FA
</button>
<button
mat-stroked-button
color="warn"
[disabled]="!currentHwItem.mfaEnabled || currentHwItem.busy"
(click)="disableDscMfa(currentHwItem)"
>
<fa-icon icon="ban" class="ac-hw-btn-icon"></fa-icon>
Disable for 2FA
</button>
}
} @else {
<button mat-stroked-button color="warn" (click)="removeHardware(currentHwItem)">
<mat-icon>link_off</mat-icon> Unregister
</button>
@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>
@@ -15,12 +15,14 @@ import { MatFormField, MatLabel, MatPrefix, MatHint, MatError } from '@angular/m
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 { DisableConfirmDialogComponent } from './disable-confirm-dialog/disable-confirm-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';
import { DscService } from 'app/users/dsc/dsc.service';
import { EnrollDscDialogComponent } from 'app/users/dsc/enroll-dsc-dialog/enroll-dsc-dialog.component';
type HwSubKind = 'smart-card' | 'digital-signature' | 'biometric';
type MethodKey = 'sms' | 'email' | 'totp' | 'hardware';
@@ -63,6 +65,8 @@ interface HardwareItem {
serial: string;
registeredAt: string;
busy: boolean;
/** DSC-only: whether the mapped DSC is currently enabled as a 2FA factor. */
mfaEnabled?: boolean;
}
interface PersistedPrefs {
@@ -97,6 +101,7 @@ interface PersistedPrefs {
export class AccessControlComponent implements OnInit, OnDestroy {
private authenticationService = inject(AuthenticationService);
private accessControlService = inject(AccessControlService);
private dscService = inject(DscService);
private usersService = inject(UsersService);
private dialog = inject(MatDialog);
@@ -182,6 +187,7 @@ export class AccessControlComponent implements OnInit, OnDestroy {
if (this.mfaFactorsApiAvailable) {
this.hydrateFromServer();
}
this.hydrateDscState();
// 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) {
@@ -469,13 +475,21 @@ export class AccessControlComponent implements OnInit, OnDestroy {
disableOtpChannel(channel: OtpChannelState): void {
const label = channel.typeCode === 'SMS' ? 'SMS OTP' : 'Email OTP';
const destination =
channel.typeCode === 'SMS' && channel.destination ? `+91 ${channel.destination}` : channel.destination || '';
this.dialog
.open(DeleteDialogComponent, {
data: { deleteContext: `${label} as your MFA method` }
.open(DisableConfirmDialogComponent, {
width: '440px',
autoFocus: false,
data: {
title: `Disable ${label}`,
message: `${label} will no longer be used as a second factor for sign-in. You can re-enable it later.`,
detail: destination ? `Currently active for ${destination}` : ''
}
})
.afterClosed()
.subscribe((response: any) => {
if (!response?.delete) return;
if (!response?.confirmed) return;
const finish = () => {
channel.verified = false;
channel.otp = '';
@@ -620,6 +634,58 @@ export class AccessControlComponent implements OnInit, OnDestroy {
this.savePrefs();
}
/** Open the DSC enrollment dialog; if it returns success, mark the tile as mapped. */
openDscEnroll(item: HardwareItem): void {
const ref = this.dialog.open(EnrollDscDialogComponent, {
width: '640px',
disableClose: true,
autoFocus: false,
panelClass: 'ed-dialog-panel'
});
ref.afterClosed().subscribe((result: any) => {
if (!result?.enrolled) return;
const dsc = result.dsc;
item.registered = true;
item.serial = dsc?.dscSerialNoFull || dsc?.dscSerialNoMasked || '';
item.registeredAt = new Date().toLocaleString();
item.mfaEnabled = false;
this.savePrefs();
});
}
enableDscMfa(item: HardwareItem): void {
item.mfaEnabled = true;
this.savePrefs();
}
disableDscMfa(item: HardwareItem): void {
item.mfaEnabled = false;
this.savePrefs();
}
/**
* Reconcile the DSC tile with the server: if /v1/dsc/me returns at least one ACTIVE
* DSC, the tile is considered "registered". Called from ngOnInit alongside the
* existing OTP/MFA-factor hydration.
*/
private hydrateDscState(): void {
const dscItem = this.hardware.find((h) => h.kind === 'digital-signature');
if (!dscItem) return;
this.dscService.listMyDscs().subscribe({
next: (dscs) => {
const active = (dscs || []).find((d) => (d?.certStatus || '').toUpperCase() === 'ACTIVE');
if (active) {
dscItem.registered = true;
dscItem.serial = active.dscSerialNoMasked || active.dscSerialNoFull || dscItem.serial;
dscItem.registeredAt = dscItem.registeredAt || new Date().toLocaleString();
}
},
error: () => {
// Silent — keep whatever locally-cached state we have.
}
});
}
private randomSerial(kind: HwSubKind): string {
const prefix = kind === 'smart-card' ? 'SC' : kind === 'digital-signature' ? 'DSC' : 'BIO';
let body = '';
@@ -0,0 +1,27 @@
<!--
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/.
-->
<h2 mat-dialog-title class="dcd-title">
<fa-icon icon="ban" class="dcd-title__icon"></fa-icon>
<span class="dcd-title__text">{{ data.title }}</span>
</h2>
<mat-dialog-content class="dcd-content">
<p class="dcd-msg">{{ data.message }}</p>
@if (data.detail) {
<p class="dcd-detail">{{ data.detail }}</p>
}
</mat-dialog-content>
<mat-dialog-actions align="end" class="dcd-actions">
<button mat-stroked-button type="button" (click)="cancel()">Cancel</button>
<button mat-flat-button color="warn" type="button" (click)="confirm()">
<fa-icon icon="ban" class="dcd-btn-icon"></fa-icon>
<span>Disable</span>
</button>
</mat-dialog-actions>
@@ -0,0 +1,70 @@
/**
* 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/.
*/
:host {
display: block;
min-width: 380px;
max-width: 480px;
}
.dcd-title {
display: flex !important;
align-items: center;
gap: 10px;
margin: 0 !important;
padding: 16px 24px 4px !important;
font-size: 1.05rem !important;
font-weight: 600 !important;
color: #b91c1c !important;
&__icon {
color: #dc2626;
font-size: 18px;
flex-shrink: 0;
}
&__text {
color: #b91c1c;
flex: 1;
}
}
.dcd-content {
padding: 8px 24px 16px !important;
color: #334155 !important;
display: block !important;
}
.dcd-msg {
margin: 0 0 8px !important;
font-size: 0.95rem;
line-height: 1.5;
color: #334155;
}
.dcd-detail {
margin: 0 !important;
font-size: 0.85rem;
color: #64748b;
font-style: italic;
}
.dcd-actions {
padding: 8px 20px 16px !important;
gap: 8px;
button {
display: inline-flex;
align-items: center;
gap: 6px;
}
.dcd-btn-icon {
font-size: 14px;
}
}
@@ -0,0 +1,52 @@
/**
* 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, inject } from '@angular/core';
import {
MAT_DIALOG_DATA,
MatDialogRef,
MatDialogTitle,
MatDialogContent,
MatDialogActions
} from '@angular/material/dialog';
import { MatButton } from '@angular/material/button';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
export interface DisableConfirmDialogData {
/** Heading shown at the top, e.g. "Disable SMS OTP". */
title: string;
/** Body paragraph explaining the impact. */
message: string;
/** Optional muted line shown below the message (the destination being disabled). */
detail?: string;
}
@Component({
selector: 'mifosx-disable-confirm-dialog',
templateUrl: './disable-confirm-dialog.component.html',
styleUrls: ['./disable-confirm-dialog.component.scss'],
imports: [
MatDialogTitle,
MatDialogContent,
MatDialogActions,
MatButton,
FaIconComponent
]
})
export class DisableConfirmDialogComponent {
data = inject<DisableConfirmDialogData>(MAT_DIALOG_DATA);
private dialogRef = inject<MatDialogRef<DisableConfirmDialogComponent>>(MatDialogRef);
cancel(): void {
this.dialogRef.close({ confirmed: false });
}
confirm(): void {
this.dialogRef.close({ confirmed: true });
}
}