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
@@ -13,16 +13,22 @@
<h2 class="tfa-title">{{ 'labels.text.Two Factor Authentication' | translate }}</h2>
<p class="tfa-sub">
@if (!otpRequested) {
Choose how you want to receive your one-time passcode.
@if (dscChallengeActive) {
Plug in your DSC token and follow the prompts in the helper.
} @else if (!otpRequested) {
Choose how you want to verify your identity.
} @else {
Enter the 6-digit code we sent you to continue.
}
</p>
<!-- Step 1 — Pick a delivery method -->
@if (!otpRequested) {
<form class="tfa-form" [formGroup]="twoFactorAuthenticationDeliveryMethodForm" (ngSubmit)="requestOTP()">
@if (!otpRequested && !dscChallengeActive) {
<form
class="tfa-form"
[formGroup]="twoFactorAuthenticationDeliveryMethodForm"
(ngSubmit)="continueWithSelectedFactor()"
>
<div class="tfa-methods" formGroupName="">
<mat-radio-group class="tfa-methods__group" formControlName="twoFactorAuthenticationDeliveryMethod">
@for (method of twoFactorAuthenticationDeliveryMethods; track method.name) {
@@ -46,17 +52,17 @@
(click)="$event.stopPropagation()"
></mat-radio-button>
<div class="tfa-method__icon">
<fa-icon
[icon]="
(method.name | lowercase) === 'sms' || (method.name | lowercase) === 'mobile'
? 'mobile-alt'
: 'envelope'
"
></fa-icon>
<fa-icon [icon]="iconFor(method)"></fa-icon>
</div>
<div class="tfa-method__body">
<span class="tfa-method__name">{{ method.name | titlecase }}</span>
<span class="tfa-method__target">{{ maskTarget(method) }}</span>
<span class="tfa-method__target">
@if (method.kind === 'DSC') {
{{ method.target }}
} @else {
{{ maskTarget(method) }}
}
</span>
</div>
</div>
}
@@ -73,23 +79,66 @@
<span>Sending…</span>
} @else {
<fa-icon icon="paper-plane"></fa-icon>
<span>{{ 'labels.buttons.Request OTP' | translate }}</span>
<span>Continue</span>
}
</button>
</form>
}
<!-- DSC challenge sub-screen — replaces the OTP input when DSC was picked. -->
@if (dscChallengeActive) {
<div class="tfa-context tfa-context--dsc">
<fa-icon icon="file-signature"></fa-icon>
<div>
<span class="tfa-context__label">Signing as</span>
<strong class="tfa-context__target">{{ selectedTwoFactorAuthenticationDeliveryMethod?.target }}</strong>
</div>
@if (dscCountdownLabel) {
<span class="tfa-context__validity">
<fa-icon icon="clock"></fa-icon>
{{ dscCountdownLabel }} remaining
</span>
}
</div>
<div class="tfa-dsc-hint">
<fa-icon icon="info-circle"></fa-icon>
<span>
A verification helper has been downloaded. If your browser didn't open it, click
<strong>Re-download helper</strong>. Need OpenWebStart?
<a href="https://openwebstart.com/" target="_blank" rel="noopener">Install it</a>.
</span>
</div>
@if (dscDetecting) {
<div class="tfa-dsc-spinner">
<mat-spinner diameter="36"></mat-spinner>
<span>Waiting for DSC verification…</span>
</div>
}
@if (dscFailed) {
<div class="tfa-error">
<fa-icon icon="exclamation-circle"></fa-icon>
{{ dscErrorMessage }}
</div>
}
<button type="button" class="tfa-btn tfa-btn--ghost" (click)="reDownloadDscHelper()" [disabled]="loading">
<fa-icon icon="sync-alt"></fa-icon>
<span>Re-download helper</span>
</button>
<button type="button" class="tfa-btn tfa-btn--ghost" (click)="useAnotherMethod()" [disabled]="loading">
<fa-icon icon="arrow-left"></fa-icon>
<span>Use another method</span>
</button>
}
<!-- Step 2 — Enter the code -->
@if (otpRequested) {
@if (otpRequested && !dscChallengeActive) {
<div class="tfa-context">
<fa-icon
[icon]="
(selectedTwoFactorAuthenticationDeliveryMethod.name | lowercase) === 'sms' ||
(selectedTwoFactorAuthenticationDeliveryMethod.name | lowercase) === 'mobile'
? 'mobile-alt'
: 'envelope'
"
></fa-icon>
<fa-icon [icon]="iconFor(selectedTwoFactorAuthenticationDeliveryMethod)"></fa-icon>
<div>
<span class="tfa-context__label">Code sent to</span>
<strong class="tfa-context__target">{{ maskTarget(selectedTwoFactorAuthenticationDeliveryMethod) }}</strong>
@@ -154,6 +154,53 @@
}
}
/* ──────── DSC challenge sub-screen ──────── */
.tfa-context--dsc {
> fa-icon {
color: #a78bfa;
}
}
.tfa-dsc-hint {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 12px 14px;
margin-bottom: 14px;
border-radius: 10px;
background: rgb(59 130 246 / 12%);
border: 1px solid rgb(59 130 246 / 25%);
color: rgb(255 255 255 / 88%);
font-size: 13px;
line-height: 1.45;
> fa-icon {
color: #93c5fd;
margin-top: 2px;
flex-shrink: 0;
}
strong {
color: #fff;
font-weight: 600;
}
a {
color: #93c5fd;
text-decoration: underline;
}
}
.tfa-dsc-spinner {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 18px 0 8px;
color: rgb(255 255 255 / 78%);
font-size: 13px;
}
/* ──────── Step 2 — context strip ──────── */
.tfa-context {
display: flex;
@@ -7,14 +7,17 @@
*/
/** Angular Imports */
import { Component, OnInit, inject } from '@angular/core';
import { Component, OnDestroy, OnInit, inject } from '@angular/core';
import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
/** rxjs Imports */
import { finalize } from 'rxjs/operators';
import { Subject, Subscription, interval, takeUntil } from 'rxjs';
/** Custom Services */
import { AuthenticationService } from '../../core/authentication/authentication.service';
import { DscService } from '../../users/dsc/dsc.service';
import { BankUserDsc, DscLoginChallenge } from '../../users/dsc/dsc.model';
import { MatRadioGroup, MatRadioButton } from '@angular/material/radio';
import { MatProgressSpinner } from '@angular/material/progress-spinner';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
@@ -35,9 +38,10 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
FaIconComponent
]
})
export class TwoFactorAuthenticationComponent implements OnInit {
export class TwoFactorAuthenticationComponent implements OnInit, OnDestroy {
private formBuilder = inject(UntypedFormBuilder);
private authenticationService = inject(AuthenticationService);
private dscService = inject(DscService);
/** Available delivery methods to receive OTP. */
twoFactorAuthenticationDeliveryMethods: any;
@@ -56,6 +60,21 @@ export class TwoFactorAuthenticationComponent implements OnInit {
/** True if loading. */
resendOTPLoading = false;
/** True once the user picked DSC and the challenge sub-screen is active. */
dscChallengeActive = false;
/** Currently-running DSC login challenge (set by requestDscChallenge). */
dscChallenge: DscLoginChallenge | null = null;
/** Status flags for the DSC challenge sub-screen. */
dscDetecting = false;
dscFailed = false;
dscErrorMessage = '';
/** Seconds remaining on the DSC challenge — driven off server response. */
dscSecondsRemaining = 0;
dscCountdownLabel = '';
private dscPollSub?: Subscription;
private destroy$ = new Subject<void>();
/**
* Creates two factor authentication delivery method form.
*
@@ -63,22 +82,68 @@ export class TwoFactorAuthenticationComponent implements OnInit {
*/
ngOnInit() {
this.createTwoFactorAuthenticationDeliveryMethodForm();
this.authenticationService.getDeliveryMethods().subscribe((deliveryMethods: any) => {
// Keep only methods the user has actually configured (target populated).
// Methods returned without a destination are noise from the backend.
const enabled = (deliveryMethods || []).filter((m: any) => !!m?.target);
this.twoFactorAuthenticationDeliveryMethods = enabled;
// If exactly one method is enabled, skip the picker and request OTP automatically.
if (enabled.length === 1) {
this.twoFactorAuthenticationDeliveryMethodForm
.get('twoFactorAuthenticationDeliveryMethod')
?.setValue(enabled[0]);
this.requestOTP();
// 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({
next: (deliveryMethods: any) => {
const enabled = (deliveryMethods || []).filter((m: any) => !!m?.target);
this.dscService.listMyDscs().subscribe({
next: (dscs) => this.mergeFactors(enabled, dscs || []),
error: () => this.mergeFactors(enabled, [])
});
},
error: () => {
// If delivery-methods call fails, still try to surface DSC.
this.dscService.listMyDscs().subscribe({
next: (dscs) => this.mergeFactors([], dscs || []),
error: () => this.mergeFactors([], [])
});
}
});
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
this.dscPollSub?.unsubscribe();
}
/**
* Merge OTP delivery methods with ACTIVE DSCs into a single list of factors.
* DSC factors carry `kind: 'DSC'` so the picker and Continue button can branch.
*/
private mergeFactors(otpMethods: any[], dscs: BankUserDsc[]): void {
const dscFactors = (dscs || [])
.filter((d) => (d?.certStatus || '').toUpperCase() === 'ACTIVE')
.map((d) => ({
kind: 'DSC',
name: 'DSC',
target: d.commonName || d.dscName || d.dscSerialNoMasked || `DSC #${d.id}`,
dsc: d
}));
const combined = [
...otpMethods.map((m) => ({ ...m, kind: 'OTP' })),
...dscFactors
];
this.twoFactorAuthenticationDeliveryMethods = combined;
if (combined.length === 1) {
this.twoFactorAuthenticationDeliveryMethodForm
.get('twoFactorAuthenticationDeliveryMethod')
?.setValue(combined[0]);
this.continueWithSelectedFactor();
}
}
/** Branches on the picked factor: OTP path vs DSC challenge path. */
continueWithSelectedFactor(): void {
const method = this.twoFactorAuthenticationDeliveryMethodForm.value.twoFactorAuthenticationDeliveryMethod;
if (method?.kind === 'DSC') {
this.requestDscChallenge(method);
} else {
this.requestOTP();
}
}
/**
* Requests OTP via the selected delivery method.
*/
@@ -131,6 +196,149 @@ export class TwoFactorAuthenticationComponent implements OnInit {
this.twoFactorAuthenticationDeliveryMethodForm.get('twoFactorAuthenticationDeliveryMethod')?.setValue(method);
}
/** Icon name for a factor row in the picker / context strip. */
iconFor(method: any): string {
const name = (method?.name || '').toLowerCase();
if (method?.kind === 'DSC' || name === 'dsc') return 'signature';
if (name === 'sms' || name === 'mobile' || name === 'phone') return 'mobile-alt';
return 'envelope';
}
/** Start a DSC login challenge: POST /challenge → fire JNLP → begin polling. */
requestDscChallenge(method: any): void {
this.loading = true;
this.dscFailed = false;
this.dscErrorMessage = '';
this.dscChallengeActive = true;
this.selectedTwoFactorAuthenticationDeliveryMethod = method;
this.twoFactorAuthenticationDeliveryMethodForm.disable();
this.dscService
.requestLoginChallenge()
.pipe(finalize(() => (this.loading = false)))
.subscribe({
next: (challenge) => {
this.dscChallenge = challenge;
this.dscSecondsRemaining = challenge.expiresInSeconds || 120;
this.dscDetecting = true;
this.tickDscCountdown();
// Pass the whole challenge — the service builds the JNLP locally and bypasses
// JWS server fetches entirely.
this.dscService.downloadAndOpenJnlp(challenge);
this.beginDscPolling();
},
error: (err) => {
this.dscDetecting = false;
this.dscFailed = true;
this.dscErrorMessage =
err?.error?.defaultUserMessage ||
err?.error?.message ||
err?.message ||
'Could not start DSC verification.';
}
});
}
/** Re-fire the JNLP without restarting the challenge. */
reDownloadDscHelper(): void {
if (this.dscChallenge) this.dscService.downloadAndOpenJnlp(this.dscChallenge);
}
/** Drop back to the factor picker without consuming the challenge. */
useAnotherMethod(): void {
this.dscPollSub?.unsubscribe();
this.dscChallengeActive = false;
this.dscChallenge = null;
this.dscDetecting = false;
this.dscFailed = false;
this.dscErrorMessage = '';
this.selectedTwoFactorAuthenticationDeliveryMethod = null;
this.twoFactorAuthenticationDeliveryMethodForm.enable();
this.twoFactorAuthenticationDeliveryMethodForm.reset();
}
private beginDscPolling(): void {
if (!this.dscChallenge?.randomNo) return;
const timeoutMs = (this.dscChallenge.expiresInSeconds || 120) * 1000;
this.dscPollSub?.unsubscribe();
this.dscPollSub = this.dscService
.pollStatus(() => this.dscService.getLoginStatus(this.dscChallenge!.randomNo), timeoutMs)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (res) => {
if (res.status === 'VERIFIED') {
this.dscDetecting = false;
this.claimAndValidate(this.dscChallenge!.randomNo);
} else if (res.status === 'FAILED' || res.status === 'EXPIRED') {
this.dscDetecting = false;
this.dscFailed = true;
this.dscErrorMessage =
(res as any).errorMessage ||
(res.status === 'EXPIRED' ? 'Verification took too long — try again.' : 'DSC verification failed.');
}
},
complete: () => {
if (this.dscDetecting) {
this.dscDetecting = false;
this.dscFailed = true;
this.dscErrorMessage = 'Verification took too long — try again.';
}
},
error: (err) => {
this.dscDetecting = false;
this.dscFailed = true;
this.dscErrorMessage = err?.error?.defaultUserMessage || err?.message || 'Polling failed.';
}
});
}
/** After /status reports VERIFIED, fetch the TFA token via /claim and finalise login. */
private claimAndValidate(randomNo: string): void {
this.loading = true;
this.dscService
.claimLoginToken(randomNo)
.pipe(finalize(() => (this.loading = false)))
.subscribe({
next: (res: any) => {
const token: string | null = res?.tfaToken || null;
if (!token) {
this.dscFailed = true;
this.dscErrorMessage =
res?.errorMessage || 'DSC verified, but the verification token could not be claimed.';
return;
}
// Reuse the existing /twofactor/validate pipeline; backend treats the
// DSC-issued bearer token the same as an OTP code.
this.authenticationService.validateOTP(token).subscribe({
error: (err) => {
this.dscFailed = true;
this.dscErrorMessage =
err?.error?.defaultUserMessage || err?.error?.message || err?.message || 'Token rejected by server.';
}
});
},
error: (err) => {
this.dscFailed = true;
this.dscErrorMessage =
err?.error?.defaultUserMessage ||
err?.error?.message ||
err?.message ||
'Could not claim verification token.';
}
});
}
private tickDscCountdown(): void {
interval(1000)
.pipe(takeUntil(this.destroy$))
.subscribe(() => {
if (this.dscSecondsRemaining > 0) this.dscSecondsRemaining--;
const m = Math.floor(this.dscSecondsRemaining / 60);
const s = this.dscSecondsRemaining % 60;
this.dscCountdownLabel = `${m}:${String(s).padStart(2, '0')}`;
});
}
/**
* Mask the delivery target so only the last 4 characters are visible.
* Mobile → "+XXXXXX2076"; Email → "ad***@gmail.com".