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:
co-authored by
Claude Opus 4.7
parent
3b752dc6ae
commit
09fab88451
@@ -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 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user