- @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.
}
- @if (!otpRequested) {
-
+
+
+ Signing as
+ {{ selectedTwoFactorAuthenticationDeliveryMethod?.target }}
+
+ @if (dscCountdownLabel) {
+
+
+ {{ dscCountdownLabel }} remaining
+
+ }
+
+
+
-
+
Code sent to
{{ maskTarget(selectedTwoFactorAuthenticationDeliveryMethod) }}
diff --git a/src/app/login/two-factor-authentication/two-factor-authentication.component.scss b/src/app/login/two-factor-authentication/two-factor-authentication.component.scss
index 1728e0d4d..e76036d5f 100644
--- a/src/app/login/two-factor-authentication/two-factor-authentication.component.scss
+++ b/src/app/login/two-factor-authentication/two-factor-authentication.component.scss
@@ -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;
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 1e05cf143..768cda2cd 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
@@ -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
();
+
/**
* 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".
diff --git a/src/app/users/create-user/create-user.component.html b/src/app/users/create-user/create-user.component.html
index 096865728..519399995 100644
--- a/src/app/users/create-user/create-user.component.html
+++ b/src/app/users/create-user/create-user.component.html
@@ -86,7 +86,7 @@
formControlName="email"
placeholder="name@example.com"
/>
- @if (userForm.controls.email.hasError('email')) {
+ @if (userForm.controls.email.hasError('email') || userForm.controls.email.hasError('pattern')) {
{{ 'labels.inputs.Email' | translate }} {{ 'labels.commons.is' | translate }}
{{ 'labels.commons.invalid' | translate }}
+
+
+
+
+
+ Digital Signatures
+
+
+
+ Map New DSC
+
+
+
+ @if (loading) {
+
+ }
+
+
+
+
+ Common Name
+ {{ row.commonName || '—' }}
+
+
+
+ Serial
+
+ {{ row.dscSerialNoFull || row.dscSerialNoMasked || '—' }}
+
+
+
+
+ Issuer
+
+ {{ shortenDn(row.issuedBy) || '—' }}
+
+
+
+
+ Expires
+ {{ formatDate(row.dscExpiryDate) }}
+
+
+
+ Status
+
+
+
+ {{ statusLabel(row.certStatus) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ View details
+
+
+
+ Revoke
+
+
+
+
+
+
+
+
+
+ @if (!loading && errorStatus) {
+
+
+
+
+ @if (errorStatus === 403) {
+ Access denied (HTTP 403)
+ } @else if (errorStatus === 401) {
+ Sign in required (HTTP 401)
+ } @else {
+ Failed to load (HTTP {{ errorStatus }})
+ }
+
+ {{ errorMsg }}
+
+
+ } @else if (!loading && dataSource.data.length === 0) {
+
+
+
No DSC mapped — required only if you want DSC-based two-factor auth.
+
+ }
+
+
diff --git a/src/app/users/dsc/dsc-list/dsc-list.component.scss b/src/app/users/dsc/dsc-list/dsc-list.component.scss
new file mode 100644
index 000000000..652b21537
--- /dev/null
+++ b/src/app/users/dsc/dsc-list/dsc-list.component.scss
@@ -0,0 +1,163 @@
+/**
+ * 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/.
+ */
+
+.dsc-card {
+ background: #fff;
+ border: 1px solid #e2e8f0;
+ border-radius: 12px;
+ box-shadow: 0 1px 2px rgb(15 23 42 / 4%);
+ overflow: hidden;
+}
+
+.dsc-card__head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 16px 20px;
+ border-bottom: 1px solid #e2e8f0;
+ background: linear-gradient(180deg, #f8fafc 0%, #fff 100%);
+}
+
+.dsc-card__title {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ font-size: 1.05rem;
+ font-weight: 600;
+ color: #0f172a;
+}
+
+.dsc-card__title-icon {
+ color: #3b82f6;
+}
+
+.dsc-card__action {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.dsc-card__body {
+ padding: 0;
+}
+
+.dsc-table {
+ width: 100%;
+
+ th.mat-mdc-header-cell {
+ background: #f8fafc;
+ color: #475569;
+ font-weight: 600;
+ font-size: 0.78rem;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ }
+
+ td.mat-mdc-cell {
+ color: #0f172a;
+ font-size: 0.92rem;
+ }
+}
+
+.dsc-mono {
+ font-family: 'SF Mono', Menlo, Consolas, monospace;
+ font-size: 0.85rem;
+ letter-spacing: 0.04em;
+}
+
+.dsc-actions-col {
+ width: 48px;
+ text-align: right;
+}
+
+.dsc-menu-icon {
+ margin-right: 10px;
+ color: #475569;
+
+ &--danger {
+ color: #dc2626;
+ }
+}
+
+.dsc-pill {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 4px 10px;
+ border-radius: 999px;
+ font-size: 0.78rem;
+ font-weight: 600;
+
+ &__dot {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ background: currentcolor;
+ }
+
+ &.is-active {
+ background: #dcfce7;
+ color: #16a34a;
+ }
+
+ &.is-expired {
+ background: #ffedd5;
+ color: #ea580c;
+ }
+
+ &.is-revoked {
+ background: #fee2e2;
+ color: #dc2626;
+ }
+}
+
+.dsc-state {
+ display: flex;
+ align-items: flex-start;
+ gap: 12px;
+ padding: 24px 20px;
+ color: #475569;
+
+ fa-icon {
+ font-size: 1.5rem;
+ margin-top: 2px;
+ }
+
+ strong {
+ display: block;
+ color: #0f172a;
+ margin-bottom: 4px;
+ }
+
+ span {
+ font-size: 0.88rem;
+ }
+
+ p {
+ margin: 0;
+ font-size: 0.92rem;
+ }
+
+ &--error {
+ background: #fef2f2;
+ color: #b91c1c;
+ }
+
+ &--empty {
+ justify-content: center;
+ text-align: center;
+ flex-direction: column;
+ align-items: center;
+ padding: 36px 20px;
+
+ fa-icon {
+ font-size: 2rem;
+ color: #cbd5e1;
+ }
+ }
+}
diff --git a/src/app/users/dsc/dsc-list/dsc-list.component.ts b/src/app/users/dsc/dsc-list/dsc-list.component.ts
new file mode 100644
index 000000000..429c65c72
--- /dev/null
+++ b/src/app/users/dsc/dsc-list/dsc-list.component.ts
@@ -0,0 +1,191 @@
+/**
+ * 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, Input, OnInit, inject } from '@angular/core';
+import {
+ MatTableDataSource,
+ MatTable,
+ MatColumnDef,
+ MatHeaderCellDef,
+ MatHeaderCell,
+ MatCellDef,
+ MatCell,
+ MatHeaderRowDef,
+ MatHeaderRow,
+ MatRowDef,
+ MatRow
+} from '@angular/material/table';
+import { MatButton, MatIconButton } from '@angular/material/button';
+import { MatIcon } from '@angular/material/icon';
+import { MatMenu, MatMenuItem, MatMenuTrigger } from '@angular/material/menu';
+import { MatTooltip } from '@angular/material/tooltip';
+import { MatProgressBar } from '@angular/material/progress-bar';
+import { MatDialog } from '@angular/material/dialog';
+import { FaIconComponent } from '@fortawesome/angular-fontawesome';
+
+import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
+import { DscService } from '../dsc.service';
+import { BankUserDsc } from '../dsc.model';
+import { EnrollDscDialogComponent } from '../enroll-dsc-dialog/enroll-dsc-dialog.component';
+import { RevokeDscDialogComponent } from '../revoke-dsc-dialog/revoke-dsc-dialog.component';
+
+@Component({
+ selector: 'mifosx-dsc-list',
+ templateUrl: './dsc-list.component.html',
+ styleUrls: ['./dsc-list.component.scss'],
+ imports: [
+ ...STANDALONE_SHARED_IMPORTS,
+ MatTable,
+ MatColumnDef,
+ MatHeaderCellDef,
+ MatHeaderCell,
+ MatCellDef,
+ MatCell,
+ MatHeaderRowDef,
+ MatHeaderRow,
+ MatRowDef,
+ MatRow,
+ MatButton,
+ MatIconButton,
+ MatIcon,
+ MatMenu,
+ MatMenuItem,
+ MatMenuTrigger,
+ MatTooltip,
+ MatProgressBar,
+ FaIconComponent
+ ]
+})
+export class DscListComponent implements OnInit {
+ private service = inject(DscService);
+ private dialog = inject(MatDialog);
+
+ /** When set, the list is loaded for a specific user (admin view). Otherwise loads /me. */
+ @Input() userId?: number;
+
+ loading = false;
+ errorStatus = 0;
+ errorMsg = '';
+ dataSource = new MatTableDataSource([]);
+
+ readonly displayedColumns = [
+ 'commonName',
+ 'serial',
+ 'issuer',
+ 'expiresOn',
+ 'status',
+ 'actions'
+ ];
+
+ ngOnInit(): void {
+ this.fetch();
+ }
+
+ fetch(): void {
+ this.loading = true;
+ this.errorStatus = 0;
+ this.errorMsg = '';
+ const source$ = this.userId != null ? this.service.listForUser(this.userId) : this.service.listMyDscs();
+ source$.subscribe({
+ next: (rows) => {
+ this.loading = false;
+ this.dataSource.data = rows || [];
+ },
+ error: (err) => {
+ this.loading = false;
+ this.dataSource.data = [];
+ this.errorStatus = err?.status || 0;
+ this.errorMsg =
+ err?.error?.defaultUserMessage || err?.error?.message || err?.message || 'Failed to load DSC list.';
+ }
+ });
+ }
+
+ enroll(): void {
+ const ref = this.dialog.open(EnrollDscDialogComponent, {
+ width: '640px',
+ disableClose: true,
+ autoFocus: false,
+ panelClass: 'ed-dialog-panel'
+ });
+ ref.afterClosed().subscribe((result) => {
+ if (result?.enrolled) this.fetch();
+ });
+ }
+
+ revoke(row: BankUserDsc): void {
+ const ref = this.dialog.open(RevokeDscDialogComponent, {
+ width: '480px',
+ data: { dsc: row },
+ autoFocus: false
+ });
+ ref.afterClosed().subscribe((result) => {
+ if (result?.revoked) this.fetch();
+ });
+ }
+
+ view(row: BankUserDsc): void {
+ if (row?.id == null) return;
+ this.service.getDsc(row.id).subscribe((full) => {
+ const merged = this.dataSource.data.map((d) => (d.id === full.id ? { ...d, ...full } : d));
+ this.dataSource.data = merged;
+ });
+ }
+
+ statusClass(status?: string): string {
+ if (!status) return '';
+ const s = status.toUpperCase();
+ if (s === 'ACTIVE') return 'is-active';
+ if (s === 'EXPIRED') return 'is-expired';
+ if (s === 'REVOKED') return 'is-revoked';
+ return '';
+ }
+
+ statusLabel(status?: string): string {
+ if (!status) return '—';
+ const s = status.toUpperCase();
+ if (s === 'ACTIVE') return 'Active';
+ if (s === 'EXPIRED') return 'Expired';
+ if (s === 'REVOKED') return 'Revoked';
+ return s;
+ }
+
+ formatDate(value: any): string {
+ if (!value) return '—';
+ if (Array.isArray(value) && value.length >= 3) {
+ const [
+ y,
+ m,
+ d
+ ] = value;
+ return new Date(y, m - 1, d).toLocaleDateString();
+ }
+ if (typeof value === 'string') {
+ const dt = new Date(value);
+ return isNaN(dt.getTime()) ? value : dt.toLocaleDateString();
+ }
+ return String(value);
+ }
+
+ /**
+ * Backend stores cert issuer as a full LDAP DN ("CN=eMudhra Sub CA for Class 3 - 2019,O=eMudhra
+ * Limited,C=IN") which is too long for the table cell. Surface just the CN; the full DN is shown
+ * on hover via matTooltip.
+ */
+ shortenDn(dn?: string | null): string {
+ if (!dn) return '';
+ for (const part of dn.split(',')) {
+ const [
+ k,
+ v
+ ] = part.trim().split('=', 2);
+ if (k && k.trim().toUpperCase() === 'CN' && v) return v.trim();
+ }
+ return dn;
+ }
+}
diff --git a/src/app/users/dsc/dsc.model.ts b/src/app/users/dsc/dsc.model.ts
new file mode 100644
index 000000000..7813779eb
--- /dev/null
+++ b/src/app/users/dsc/dsc.model.ts
@@ -0,0 +1,84 @@
+/**
+ * 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/.
+ */
+
+export type DscCertStatus = 'ACTIVE' | 'EXPIRED' | 'REVOKED';
+export type DscChallengeStatusValue = 'PENDING' | 'VERIFIED' | 'FAILED' | 'EXPIRED';
+
+/**
+ * One row in m_bank_user_dsc as returned by GET /v1/dsc/me, GET /v1/dsc/user/{userId} and
+ * GET /v1/dsc/{id}. Field names match the backend's BankUserDscData DTO verbatim. Only the
+ * full serial is null on list endpoints; everything else is populated.
+ */
+export interface BankUserDsc {
+ id: number;
+ appUserId: number;
+ username: string;
+ dscName: string;
+ /** Last six chars visible; the rest masked with bullets. Always present. */
+ dscSerialNoMasked: string;
+ /** Full serial — null on list endpoints, populated by GET /v1/dsc/{id}. */
+ dscSerialNoFull: string | null;
+ dscType: string;
+ commonName: string;
+ issuedTo: string;
+ issuedBy: string;
+ dscExpiryDate: string;
+ dateOfSubmission: string;
+ certStatus: DscCertStatus;
+ revokedOn: string | null;
+ revokeReason: string | null;
+}
+
+/** Response from POST /v1/dsc/enroll/start. */
+export interface DscEnrollSession {
+ randomNo: string;
+ jnlpUrl: string;
+ statusUrl: string;
+ expiresInSeconds: number;
+ /**
+ * Pre-stringified JSON the helper expects in its launch argument. The UI embeds this verbatim
+ * into a locally-built JNLP file's element — JWS never has to fetch a JNLP from
+ * the server.
+ */
+ helperArgsJson: string;
+ /** Relative URL the UI XHRs to fetch the helper JAR bytes (Authed via the HTTP interceptor). */
+ helperJarUrl: string;
+}
+
+/** Response from GET /v1/dsc/enroll/{rn}/status. */
+export interface DscChallengeStatus {
+ randomNo: string;
+ status: DscChallengeStatusValue;
+ errorMessage: string | null;
+ /** Set only when status === 'VERIFIED'. */
+ dsc: BankUserDsc | null;
+}
+
+/** Response from POST /v1/login/dsc/challenge. */
+export interface DscLoginChallenge {
+ randomNo: string;
+ jnlpUrl: string;
+ statusUrl: string;
+ expiresInSeconds: number;
+ /** Same shape as {@link DscEnrollSession.helperArgsJson} but for SIGN_CHALLENGE mode. */
+ helperArgsJson: string;
+ /** Same as {@link DscEnrollSession.helperJarUrl}. */
+ helperJarUrl: string;
+}
+
+/**
+ * Response from GET /v1/login/dsc/{rn}/status and POST /v1/login/dsc/{rn}/claim. The /status
+ * endpoint never sets tfaToken; the /claim endpoint sets it exactly once (single-use).
+ */
+export interface DscLoginStatus {
+ randomNo: string;
+ status: DscChallengeStatusValue;
+ errorMessage: string | null;
+ tfaToken: string | null;
+ tfaTokenLiveSeconds: number | null;
+}
diff --git a/src/app/users/dsc/dsc.service.ts b/src/app/users/dsc/dsc.service.ts
new file mode 100644
index 000000000..4ce73ad76
--- /dev/null
+++ b/src/app/users/dsc/dsc.service.ts
@@ -0,0 +1,173 @@
+/**
+ * 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 { Injectable, inject } from '@angular/core';
+import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http';
+import { Observable, interval, timer, NEVER } from 'rxjs';
+import { switchMap, takeWhile, takeUntil, startWith } from 'rxjs/operators';
+
+import { BankUserDsc, DscEnrollSession, DscChallengeStatus, DscLoginChallenge, DscLoginStatus } from './dsc.model';
+
+@Injectable({ providedIn: 'root' })
+export class DscService {
+ private http = inject(HttpClient);
+
+ /** Suppress browser native basic-auth dialog on 401 from these endpoints. */
+ private silentHeaders = new HttpHeaders({ 'X-Requested-With': 'XMLHttpRequest' });
+
+ // ── Enrollment ────────────────────────────────────────────────
+ startEnrollment(): Observable {
+ return this.http.post('/v1/dsc/enroll/start', {}, { headers: this.silentHeaders });
+ }
+
+ getEnrollStatus(randomNo: string): Observable {
+ return this.http.get(`/v1/dsc/enroll/${encodeURIComponent(randomNo)}/status`);
+ }
+
+ // ── List / detail / revoke ────────────────────────────────────
+ listMyDscs(): Observable {
+ return this.http.get('/v1/dsc/me', { headers: this.silentHeaders });
+ }
+
+ listForUser(userId: number): Observable {
+ return this.http.get(`/v1/dsc/user/${userId}`, { headers: this.silentHeaders });
+ }
+
+ getDsc(id: number): Observable {
+ return this.http.get(`/v1/dsc/${id}`, { headers: this.silentHeaders });
+ }
+
+ revokeDsc(id: number, reason: string): Observable {
+ const params = new HttpParams().set('reason', reason);
+ return this.http.delete(`/v1/dsc/${id}`, { params, headers: this.silentHeaders });
+ }
+
+ // ── Login factor ──────────────────────────────────────────────
+ requestLoginChallenge(): Observable {
+ return this.http.post('/v1/login/dsc/challenge', {}, { headers: this.silentHeaders });
+ }
+
+ getLoginStatus(randomNo: string): Observable {
+ return this.http.get(`/v1/login/dsc/${encodeURIComponent(randomNo)}/status`);
+ }
+
+ /** Single-use claim — returns the 2FA bearer token once polling has seen VERIFIED. */
+ claimLoginToken(randomNo: string): Observable {
+ return this.http.post(
+ `/v1/login/dsc/${encodeURIComponent(randomNo)}/claim`,
+ {},
+ { headers: this.silentHeaders }
+ );
+ }
+
+ // ── Helper ────────────────────────────────────────────────────
+ /**
+ * Download both the helper JAR and a locally-generated JNLP file into the user's Downloads
+ * folder. The JNLP references the JAR by RELATIVE path, so when the user double-clicks the
+ * JNLP, Java Web Start resolves '' against the JNLP's
+ * own location on disk — JWS makes ZERO HTTP requests to Fineract. This sidesteps every
+ * variant of the "Authentication Required" / tenant-filter rejection problem we hit when JWS
+ * tries to fetch resources from the server without our auth interceptor.
+ *
+ * @param session start-response from /enroll/start or /login/dsc/challenge — must carry
+ * {@code helperJarUrl} and {@code helperArgsJson}.
+ */
+ downloadAndOpenJnlp(session: { helperJarUrl?: string; helperArgsJson?: string } | string): void {
+ // Backwards-compat: if called with a string URL (old API), log and abort — the new flow
+ // requires the full session object so we can build the JNLP locally.
+ if (typeof session === 'string' || !session?.helperJarUrl || !session?.helperArgsJson) {
+ console.error('[DSC] downloadAndOpenJnlp expects {helperJarUrl, helperArgsJson} now; got:', session);
+ return;
+ }
+
+ // 1. Fetch helper JAR bytes via the authed HTTP client (interceptor adds tenant + auth headers).
+ // `silentHeaders` (X-Requested-With: XMLHttpRequest) is critical — without it, any 401
+ // response would pop up the browser's native Basic-auth dialog. With it, Fineract's auth
+ // entry point returns 401 silently and our interceptor handles it.
+ this.http.get(session.helperJarUrl, { responseType: 'blob', headers: this.silentHeaders }).subscribe({
+ next: (jarBlob) => {
+ // 2. Save the JAR to Downloads.
+ this.saveBlob(jarBlob, 'cbsnexora-dsc-helper.jar');
+
+ // 3. Build a tiny JNLP locally that references the JAR by relative path.
+ const jnlpXml = this.buildLocalJnlp(session.helperArgsJson!);
+ const jnlpBlob = new Blob([jnlpXml], { type: 'application/x-java-jnlp-file' });
+
+ // 4. Save the JNLP after a tiny delay so the first download has registered with the
+ // browser (avoids some browsers silently dropping rapid back-to-back triggers).
+ setTimeout(() => this.saveBlob(jnlpBlob, 'cbsnexora-dsc.jnlp'), 200);
+ },
+ error: (err) => {
+ console.error('[DSC] Failed to fetch helper JAR:', err);
+ }
+ });
+ }
+
+ private saveBlob(blob: Blob, filename: string): void {
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = filename;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ setTimeout(() => URL.revokeObjectURL(url), 300);
+ }
+
+ /**
+ * Generates the JNLP XML body. No {@code codebase} attribute — relative URLs resolve against
+ * the JNLP file's location on disk, so JWS reads the JAR from the same Downloads folder.
+ */
+ private buildLocalJnlp(argsJson: string): string {
+ const escaped = argsJson
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+ return `
+
+
+ CBSnexora
+ CBSnexora DSC Helper
+ Reads your DSC for CBSnexora two-factor authentication.
+
+
+
+
+
+
+
+
+
+ ${escaped}
+
+
+`;
+ }
+
+ /**
+ * Polls a status endpoint with backoff (2s for first 30s, then 5s) and a
+ * hard timeout. Emits each status as it arrives; completes on a terminal
+ * state or when the timeout fires.
+ */
+ pollStatus(fetcher: () => Observable, timeoutMs: number): Observable {
+ const fastWindowMs = 30_000;
+ return interval(2000).pipe(
+ startWith(0),
+ switchMap((tick) => {
+ const elapsed = tick * 2000;
+ if (elapsed <= fastWindowMs) return fetcher();
+ // Slow window: only fire every other tick (every 4s after 30s — close to 5s).
+ return tick % 2 === 0 ? fetcher() : NEVER;
+ }),
+ takeWhile((r) => r.status === 'PENDING', true),
+ takeUntil(timer(timeoutMs))
+ );
+ }
+}
diff --git a/src/app/users/dsc/enroll-dsc-dialog/enroll-dsc-dialog.component.html b/src/app/users/dsc/enroll-dsc-dialog/enroll-dsc-dialog.component.html
new file mode 100644
index 000000000..04cb94d69
--- /dev/null
+++ b/src/app/users/dsc/enroll-dsc-dialog/enroll-dsc-dialog.component.html
@@ -0,0 +1,229 @@
+
+
+
+
+
+
+
+
0">
+
+ @if (stepIndex > 0) {
+
+ } @else {
+ 1
+ }
+
+ Prepare
+
+
= 1">
+
1">
+
+ @if (stepIndex > 1) {
+
+ } @else {
+ 2
+ }
+
+ Detect
+
+
= 2">
+
2">
+
+ @if (stepIndex > 2) {
+
+ } @else {
+ 3
+ }
+
+ Confirm
+
+
= 3">
+
+
+ @if (stepIndex === 3) {
+
+ } @else {
+ 4
+ }
+
+ Done
+
+
+
+
+
+
+ Prepare
+ Detect
+ Confirm
+ Done
+
+
+
+ @if (stepIndex === 0) {
+ A few quick checks before we look for your token.
+
+
+
+
+
+
+
+ DSC token plugged in
+ Insert your USB token into any free port on this machine.
+
+
+
+
+
+
+
+
+ Driver installed
+ The token's driver (eMudhra / Sify / Capricorn etc.) is installed.
+
+
+
+
+
+
+
+
+ OpenWebStart available
+ Required to run the helper — Oracle Java Web Start was removed in JDK 11.
+
+
+
+
+
+
+
+ Need OpenWebStart?
+ Get the free installer for Windows, macOS, or Linux.
+
+
+
+ }
+
+
+ @if (stepIndex === 1) {
+ @if (detecting) {
+
+
+
+
+
+
Waiting for DSC detection…
+
+ A helper file was downloaded. If your browser didn't open it automatically, click
+ Re-download helper .
+
+ @if (countdownLabel) {
+
+
+ {{ countdownLabel }} remaining
+
+ }
+
+
+ Re-download helper
+
+
+ }
+
+ @if (failed) {
+
+
+
+
+
Couldn't verify your DSC
+
{{ errorMessage }}
+
Try again
+
+ }
+ }
+
+
+ @if (stepIndex === 2) {
+
+
+ We detected the following certificate. It's been mapped to your account.
+
+
+ @if (confirmedDsc) {
+
+
+ Common Name
+ {{ confirmedDsc.commonName || '—' }}
+
+
+ Issued To
+ {{ confirmedDsc.issuedTo || '—' }}
+
+
+ Issued By
+ {{ confirmedDsc.issuedBy || '—' }}
+
+
+ Serial No
+
+ {{ confirmedDsc.dscSerialNoFull || confirmedDsc.dscSerialNoMasked || '—' }}
+
+
+
+ Expires
+ {{ formatDate(confirmedDsc.dscExpiryDate) }}
+
+
+ }
+ }
+
+
+ @if (stepIndex === 3) {
+
+
+
+
+
DSC mapped successfully
+
You can now select this certificate as a second factor at sign-in.
+
+ }
+
+
+
+ @switch (stepIndex) {
+ @case (0) {
+ Cancel
+
+ Continue
+
+
+ }
+ @case (1) {
+ Cancel
+ }
+ @case (2) {
+
+ Continue
+
+
+ }
+ @default {
+ Done
+ }
+ }
+
diff --git a/src/app/users/dsc/enroll-dsc-dialog/enroll-dsc-dialog.component.scss b/src/app/users/dsc/enroll-dsc-dialog/enroll-dsc-dialog.component.scss
new file mode 100644
index 000000000..b81c477c1
--- /dev/null
+++ b/src/app/users/dsc/enroll-dsc-dialog/enroll-dsc-dialog.component.scss
@@ -0,0 +1,467 @@
+/**
+ * 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: 560px;
+ max-width: 640px;
+ font-family: inherit;
+ color: #0f172a;
+}
+
+/* ───────────────────────── Hero header ───────────────────────── */
+.ed-hero {
+ display: flex;
+ align-items: center;
+ gap: 14px;
+ padding: 22px 24px 18px;
+ background: linear-gradient(135deg, #eff6ff 0%, #fff 60%);
+ border-bottom: 1px solid #dbeafe;
+
+ &__icon {
+ width: 48px;
+ height: 48px;
+ border-radius: 12px;
+ background: #1e40af;
+ color: #fff;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 20px;
+ flex-shrink: 0;
+ }
+
+ &__text {
+ flex: 1;
+ min-width: 0;
+ }
+
+ &__title {
+ margin: 0 0 4px;
+ font-size: 18px;
+ font-weight: 700;
+ color: #0f172a;
+ }
+
+ &__sub {
+ margin: 0;
+ font-size: 13px;
+ color: #475569;
+ line-height: 1.5;
+ }
+}
+
+/* ───────────────────────── Step indicator ───────────────────────── */
+.ed-steps {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 18px 24px;
+ background: #fff;
+ border-bottom: 1px solid #e2e8f0;
+}
+
+.ed-step {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ color: #94a3b8;
+ font-size: 13px;
+ font-weight: 600;
+ transition: color 0.2s ease;
+
+ &__circle {
+ width: 28px;
+ height: 28px;
+ border-radius: 50%;
+ background: #e2e8f0;
+ color: #64748b;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 12px;
+ font-weight: 700;
+ flex-shrink: 0;
+ transition:
+ background 0.2s ease,
+ color 0.2s ease;
+ }
+
+ &__bar {
+ flex: 1;
+ height: 2px;
+ background: #e2e8f0;
+ border-radius: 1px;
+ transition: background 0.2s ease;
+
+ &.is-active {
+ background: #1e40af;
+ }
+ }
+
+ &__label {
+ white-space: nowrap;
+ }
+
+ &.is-active {
+ color: #1e40af;
+
+ .ed-step__circle {
+ background: #1e40af;
+ color: #fff;
+ box-shadow: 0 0 0 4px rgb(30 64 175 / 12%);
+ }
+ }
+
+ &.is-done {
+ color: #0f172a;
+
+ .ed-step__circle {
+ background: #16a34a;
+ color: #fff;
+ }
+ }
+}
+
+/* ───────────────────────── Content area ───────────────────────── */
+.ed-content {
+ padding: 22px 24px !important;
+ background: #f8fafc;
+ max-height: 60vh;
+ overflow-y: auto;
+}
+
+/* Hide the Material stepper's default header — we render our own indicator. */
+.ed-stepper-hidden {
+ display: none !important;
+}
+
+.ed-intro {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin: 0 0 16px;
+ color: #334155;
+ font-size: 14px;
+ line-height: 1.5;
+
+ &__icon {
+ font-size: 18px;
+
+ &--ok {
+ color: #16a34a;
+ }
+ }
+}
+
+/* ───────────────────────── Prepare — requirement cards ───────────────────────── */
+.ed-checks {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ margin-bottom: 18px;
+}
+
+.ed-check {
+ display: flex;
+ align-items: center;
+ gap: 14px;
+ padding: 14px 16px;
+ background: #fff;
+ border: 1px solid #e2e8f0;
+ border-radius: 12px;
+ transition:
+ border-color 0.18s ease,
+ box-shadow 0.18s ease;
+
+ &:hover {
+ border-color: #c7d2fe;
+ box-shadow: 0 4px 14px rgb(15 23 42 / 4%);
+ }
+
+ &__icon {
+ width: 40px;
+ height: 40px;
+ border-radius: 10px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 16px;
+ flex-shrink: 0;
+
+ &--blue {
+ background: #dbeafe;
+ color: #1e40af;
+ }
+
+ &--purple {
+ background: #ede9fe;
+ color: #6d28d9;
+ }
+
+ &--green {
+ background: #dcfce7;
+ color: #15803d;
+ }
+ }
+
+ &__body {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+
+ strong {
+ color: #0f172a;
+ font-size: 14px;
+ font-weight: 600;
+ }
+
+ span {
+ color: #64748b;
+ font-size: 12.5px;
+ line-height: 1.5;
+ }
+ }
+}
+
+/* ───────────────────────── OpenWebStart callout ───────────────────────── */
+.ed-callout {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 12px 16px;
+ background: linear-gradient(135deg, #fef9c3 0%, #fff 80%);
+ border: 1px solid #fde68a;
+ border-radius: 12px;
+ text-decoration: none;
+ color: #78350f;
+ transition: transform 0.15s ease;
+
+ &:hover {
+ transform: translateY(-1px);
+ }
+
+ &__icon {
+ font-size: 18px;
+ color: #b45309;
+ flex-shrink: 0;
+ }
+
+ &__text {
+ flex: 1;
+ font-size: 13px;
+ line-height: 1.4;
+
+ strong {
+ display: block;
+ color: #92400e;
+ font-weight: 600;
+ margin-bottom: 1px;
+ }
+ }
+
+ &__chev {
+ color: #b45309;
+ flex-shrink: 0;
+ }
+}
+
+/* ───────────────────────── Detect — spinner ───────────────────────── */
+.ed-detect {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ text-align: center;
+ gap: 12px;
+ padding: 8px 8px 16px;
+
+ &__ring {
+ position: relative;
+ width: 64px;
+ height: 64px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ }
+
+ &__inner {
+ position: absolute;
+ color: #1e40af;
+ font-size: 22px;
+ }
+
+ &__title {
+ margin: 4px 0 0;
+ color: #0f172a;
+ font-size: 16px;
+ font-weight: 600;
+ }
+
+ &__sub {
+ margin: 0;
+ max-width: 440px;
+ color: #475569;
+ font-size: 13px;
+ line-height: 1.5;
+ }
+}
+
+.ed-pill {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 6px 12px;
+ border-radius: 999px;
+ background: #dbeafe;
+ color: #1e40af;
+ font-size: 12px;
+ font-weight: 600;
+
+ fa-icon {
+ font-size: 11px;
+ }
+}
+
+.ed-redownload {
+ margin-top: 4px;
+ display: inline-flex !important;
+ align-items: center;
+ gap: 8px;
+}
+
+/* ───────────────────────── Detect — error state ───────────────────────── */
+.ed-error {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ text-align: center;
+ gap: 10px;
+ padding: 16px;
+ background: #fff;
+ border: 1px solid #fecaca;
+ border-radius: 12px;
+
+ &__icon {
+ width: 56px;
+ height: 56px;
+ border-radius: 50%;
+ background: #fee2e2;
+ color: #b91c1c;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 26px;
+ }
+
+ h3 {
+ margin: 0;
+ color: #0f172a;
+ font-size: 16px;
+ }
+
+ p {
+ margin: 0;
+ color: #475569;
+ font-size: 13px;
+ max-width: 440px;
+ line-height: 1.5;
+ }
+}
+
+/* ───────────────────────── Confirm — cert details ───────────────────────── */
+.ed-cert {
+ background: #fff;
+ border: 1px solid #e2e8f0;
+ border-radius: 12px;
+ overflow: hidden;
+
+ &__row {
+ display: grid;
+ grid-template-columns: 140px 1fr;
+ gap: 16px;
+ padding: 12px 16px;
+ border-bottom: 1px solid #f1f5f9;
+
+ &:last-child {
+ border-bottom: none;
+ }
+ }
+
+ &__label {
+ color: #64748b;
+ font-size: 12.5px;
+ font-weight: 500;
+ }
+
+ &__value {
+ color: #0f172a;
+ font-size: 13px;
+ word-break: break-all;
+ }
+
+ &__mono {
+ font-family: 'SF Mono', Menlo, Consolas, monospace;
+ font-size: 12.5px;
+ letter-spacing: 0.02em;
+ }
+}
+
+/* ───────────────────────── Done ───────────────────────── */
+.ed-done {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ text-align: center;
+ gap: 12px;
+ padding: 24px 16px;
+
+ &__icon {
+ width: 72px;
+ height: 72px;
+ border-radius: 50%;
+ background: #dcfce7;
+ color: #16a34a;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 36px;
+ }
+
+ h3 {
+ margin: 4px 0 0;
+ color: #0f172a;
+ font-size: 18px;
+ font-weight: 700;
+ }
+
+ p {
+ margin: 0;
+ color: #475569;
+ font-size: 13px;
+ max-width: 380px;
+ line-height: 1.5;
+ }
+}
+
+/* ───────────────────────── Actions ───────────────────────── */
+.ed-actions {
+ padding: 14px 20px !important;
+ background: #fff;
+ border-top: 1px solid #e2e8f0;
+ gap: 10px;
+
+ button {
+ display: inline-flex !important;
+ align-items: center;
+ gap: 8px;
+ }
+
+ .ed-btn-icon {
+ font-size: 11px;
+ }
+}
diff --git a/src/app/users/dsc/enroll-dsc-dialog/enroll-dsc-dialog.component.ts b/src/app/users/dsc/enroll-dsc-dialog/enroll-dsc-dialog.component.ts
new file mode 100644
index 000000000..eb1e3cb3d
--- /dev/null
+++ b/src/app/users/dsc/enroll-dsc-dialog/enroll-dsc-dialog.component.ts
@@ -0,0 +1,191 @@
+/**
+ * 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, OnDestroy, ViewChild, inject } from '@angular/core';
+import { MatDialogRef, MatDialogTitle, MatDialogContent, MatDialogActions } from '@angular/material/dialog';
+import { MatStepper, MatStep, MatStepLabel } from '@angular/material/stepper';
+import { MatButton } from '@angular/material/button';
+import { MatProgressSpinner } from '@angular/material/progress-spinner';
+import { FaIconComponent } from '@fortawesome/angular-fontawesome';
+import { Subscription, Subject, interval, takeUntil } from 'rxjs';
+
+import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
+import { DscService } from '../dsc.service';
+import { BankUserDsc, DscEnrollSession } from '../dsc.model';
+
+@Component({
+ selector: 'mifosx-enroll-dsc-dialog',
+ templateUrl: './enroll-dsc-dialog.component.html',
+ styleUrls: ['./enroll-dsc-dialog.component.scss'],
+ imports: [
+ ...STANDALONE_SHARED_IMPORTS,
+ MatDialogTitle,
+ MatDialogContent,
+ MatDialogActions,
+ MatStepper,
+ MatStep,
+ MatStepLabel,
+ MatButton,
+ MatProgressSpinner,
+ FaIconComponent
+ ]
+})
+export class EnrollDscDialogComponent implements OnDestroy {
+ private service = inject(DscService);
+ private dialogRef = inject(MatDialogRef);
+
+ @ViewChild('stepper') stepper!: MatStepper;
+
+ /** Stepper index (0..3). */
+ stepIndex = 0;
+
+ /** Started enrollment session details. */
+ session: DscEnrollSession | null = null;
+
+ /** Confirmed DSC details (populated on VERIFIED). */
+ confirmedDsc: BankUserDsc | null = null;
+
+ /** Status flags for the Detect step. */
+ detecting = false;
+ failed = false;
+ errorMessage = '';
+
+ /** Seconds remaining for the challenge — driven off server response. */
+ secondsRemaining = 0;
+ countdownLabel = '';
+
+ private pollSub?: Subscription;
+ private destroy$ = new Subject();
+
+ next(): void {
+ this.stepper?.next();
+ this.stepIndex = this.stepper?.selectedIndex ?? this.stepIndex;
+ }
+
+ cancel(): void {
+ this.cleanup();
+ this.dialogRef.close({ enrolled: false });
+ }
+
+ done(): void {
+ this.cleanup();
+ this.dialogRef.close({ enrolled: true, dsc: this.confirmedDsc });
+ }
+
+ /** Start the enrollment: POST /start, fire JNLP, begin polling. */
+ startDetection(): void {
+ this.detecting = true;
+ this.failed = false;
+ this.errorMessage = '';
+ this.confirmedDsc = null;
+ this.next();
+
+ this.service.startEnrollment().subscribe({
+ next: (session) => {
+ this.session = session;
+ this.secondsRemaining = session.expiresInSeconds || 300;
+ this.tickCountdown();
+ // Pass the whole session: the service uses helperJarUrl + helperArgsJson to build the JNLP
+ // locally and bypass JWS server fetches entirely.
+ this.service.downloadAndOpenJnlp(session);
+ this.beginPolling();
+ },
+ error: (err) => {
+ this.detecting = false;
+ this.failed = true;
+ this.errorMessage =
+ err?.error?.defaultUserMessage || err?.error?.message || err?.message || 'Could not start enrollment.';
+ }
+ });
+ }
+
+ reDownload(): void {
+ if (this.session) this.service.downloadAndOpenJnlp(this.session);
+ }
+
+ private beginPolling(): void {
+ if (!this.session?.randomNo) return;
+ const timeoutMs = (this.session.expiresInSeconds || 300) * 1000;
+ this.pollSub?.unsubscribe();
+ this.pollSub = this.service
+ .pollStatus(() => this.service.getEnrollStatus(this.session!.randomNo), timeoutMs)
+ .pipe(takeUntil(this.destroy$))
+ .subscribe({
+ next: (res) => {
+ if (res.status === 'VERIFIED') {
+ this.detecting = false;
+ this.confirmedDsc = res.dsc || null;
+ this.next();
+ } else if (res.status === 'FAILED' || res.status === 'EXPIRED') {
+ this.detecting = false;
+ this.failed = true;
+ this.errorMessage =
+ res.errorMessage ||
+ (res.status === 'EXPIRED' ? 'Verification took too long — try again.' : 'DSC verification failed.');
+ }
+ },
+ complete: () => {
+ if (this.detecting) {
+ this.detecting = false;
+ this.failed = true;
+ this.errorMessage = 'Verification took too long — try again.';
+ }
+ },
+ error: (err) => {
+ this.detecting = false;
+ this.failed = true;
+ this.errorMessage = err?.error?.defaultUserMessage || err?.message || 'Polling failed.';
+ }
+ });
+ }
+
+ private tickCountdown(): void {
+ interval(1000)
+ .pipe(takeUntil(this.destroy$))
+ .subscribe(() => {
+ if (this.secondsRemaining > 0) this.secondsRemaining--;
+ const m = Math.floor(this.secondsRemaining / 60);
+ const s = this.secondsRemaining % 60;
+ this.countdownLabel = `${m}:${String(s).padStart(2, '0')}`;
+ });
+ }
+
+ retry(): void {
+ this.failed = false;
+ this.errorMessage = '';
+ this.stepper?.previous();
+ this.stepIndex = this.stepper?.selectedIndex ?? this.stepIndex;
+ }
+
+ formatDate(value: any): string {
+ if (!value) return '—';
+ if (Array.isArray(value) && value.length >= 3) {
+ const [
+ y,
+ m,
+ d
+ ] = value;
+ return new Date(y, m - 1, d).toLocaleDateString();
+ }
+ if (typeof value === 'string') {
+ const dt = new Date(value);
+ return isNaN(dt.getTime()) ? value : dt.toLocaleDateString();
+ }
+ return String(value);
+ }
+
+ ngOnDestroy(): void {
+ this.cleanup();
+ }
+
+ private cleanup(): void {
+ this.destroy$.next();
+ this.destroy$.complete();
+ this.pollSub?.unsubscribe();
+ }
+}
diff --git a/src/app/users/dsc/revoke-dsc-dialog/revoke-dsc-dialog.component.html b/src/app/users/dsc/revoke-dsc-dialog/revoke-dsc-dialog.component.html
new file mode 100644
index 000000000..ccef53510
--- /dev/null
+++ b/src/app/users/dsc/revoke-dsc-dialog/revoke-dsc-dialog.component.html
@@ -0,0 +1,57 @@
+
+
+
+
+ Revoke Digital Signature
+
+
+
+
+ You're about to revoke
+ {{ data.dsc.commonName || 'this DSC' }}
+ (serial {{ data.dsc.dscSerialNoFull || data.dsc.dscSerialNoMasked || '—' }}). This is permanent and the certificate
+ will no longer work for two-factor authentication.
+
+
+
+ Reason
+
+ @if (!reason?.trim() && submitting === false) {
+ Reason is required
+ }
+
+
+ @if (errorMessage) {
+
+
+ {{ errorMessage }}
+
+ }
+
+
+
+ Cancel
+
+ @if (submitting) {
+
+ Revoking…
+ } @else {
+
+ Revoke
+ }
+
+
diff --git a/src/app/users/dsc/revoke-dsc-dialog/revoke-dsc-dialog.component.scss b/src/app/users/dsc/revoke-dsc-dialog/revoke-dsc-dialog.component.scss
new file mode 100644
index 000000000..4fca8ea0b
--- /dev/null
+++ b/src/app/users/dsc/revoke-dsc-dialog/revoke-dsc-dialog.component.scss
@@ -0,0 +1,60 @@
+/**
+ * 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/.
+ */
+
+.revoke-title {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ color: #b91c1c;
+ margin: 0;
+
+ &__icon {
+ color: #dc2626;
+ }
+}
+
+.revoke-content {
+ padding-top: 8px;
+}
+
+.revoke-summary {
+ margin: 0 0 16px;
+ color: #334155;
+ font-size: 0.95rem;
+ line-height: 1.5;
+
+ strong {
+ color: #0f172a;
+ }
+}
+
+.revoke-reason {
+ width: 100%;
+}
+
+.revoke-error {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-top: 8px;
+ padding: 10px 12px;
+ border-radius: 6px;
+ background: #fef2f2;
+ color: #b91c1c;
+ font-size: 0.88rem;
+}
+
+.revoke-actions {
+ gap: 8px;
+
+ button {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ }
+}
diff --git a/src/app/users/dsc/revoke-dsc-dialog/revoke-dsc-dialog.component.ts b/src/app/users/dsc/revoke-dsc-dialog/revoke-dsc-dialog.component.ts
new file mode 100644
index 000000000..efdf51e46
--- /dev/null
+++ b/src/app/users/dsc/revoke-dsc-dialog/revoke-dsc-dialog.component.ts
@@ -0,0 +1,80 @@
+/**
+ * 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 { FormsModule } from '@angular/forms';
+import {
+ MAT_DIALOG_DATA,
+ MatDialogRef,
+ MatDialogTitle,
+ MatDialogContent,
+ MatDialogActions
+} from '@angular/material/dialog';
+import { MatButton } from '@angular/material/button';
+import { MatFormField, MatLabel, MatError } from '@angular/material/form-field';
+import { MatInput } from '@angular/material/input';
+import { MatProgressSpinner } from '@angular/material/progress-spinner';
+import { FaIconComponent } from '@fortawesome/angular-fontawesome';
+
+import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
+import { DscService } from '../dsc.service';
+import { BankUserDsc } from '../dsc.model';
+
+interface RevokeDialogData {
+ dsc: BankUserDsc;
+}
+
+@Component({
+ selector: 'mifosx-revoke-dsc-dialog',
+ templateUrl: './revoke-dsc-dialog.component.html',
+ styleUrls: ['./revoke-dsc-dialog.component.scss'],
+ imports: [
+ ...STANDALONE_SHARED_IMPORTS,
+ FormsModule,
+ MatDialogTitle,
+ MatDialogContent,
+ MatDialogActions,
+ MatButton,
+ MatFormField,
+ MatLabel,
+ MatError,
+ MatInput,
+ MatProgressSpinner,
+ FaIconComponent
+ ]
+})
+export class RevokeDscDialogComponent {
+ private service = inject(DscService);
+ private dialogRef = inject(MatDialogRef);
+ data = inject(MAT_DIALOG_DATA);
+
+ reason = '';
+ submitting = false;
+ errorMessage = '';
+
+ cancel(): void {
+ this.dialogRef.close({ revoked: false });
+ }
+
+ confirm(): void {
+ if (!this.reason?.trim() || !this.data?.dsc?.id) return;
+ this.submitting = true;
+ this.errorMessage = '';
+ this.service.revokeDsc(this.data.dsc.id, this.reason.trim()).subscribe({
+ next: () => {
+ this.submitting = false;
+ this.dialogRef.close({ revoked: true });
+ },
+ error: (err) => {
+ this.submitting = false;
+ this.errorMessage =
+ err?.error?.defaultUserMessage || err?.error?.message || err?.message || 'Could not revoke DSC.';
+ }
+ });
+ }
+}
diff --git a/src/app/users/edit-user/edit-user.component.html b/src/app/users/edit-user/edit-user.component.html
index c84caee31..537432f75 100644
--- a/src/app/users/edit-user/edit-user.component.html
+++ b/src/app/users/edit-user/edit-user.component.html
@@ -81,7 +81,7 @@
{{ 'labels.inputs.Email' | translate }}
email
- @if (editUserForm.controls.email.hasError('email')) {
+ @if (editUserForm.controls.email.hasError('email') || editUserForm.controls.email.hasError('pattern')) {
{{ 'labels.inputs.Email' | translate }} {{ 'labels.commons.is' | translate }}
{{ 'labels.commons.invalid' | translate }} {
- this.router.navigate(
- [
- '../../',
- response.resourceId
- ],
- { relativeTo: this.route }
- );
+ this.usersService.editUser(this.userData.id, editedUser).subscribe({
+ next: (response: any) => {
+ // Fineract sometimes omits resourceId in PUT responses — fall back to
+ // the user id we're editing so navigation never silently no-ops.
+ const targetId = response?.resourceId ?? this.userData.id;
+ this.snackBar.open('User updated successfully', 'OK', {
+ duration: 3000,
+ panelClass: ['success-snackbar']
+ });
+ this.router.navigate(
+ [
+ '../../',
+ targetId
+ ],
+ { relativeTo: this.route }
+ );
+ },
+ error: (err) => {
+ console.error('[EditUser] update failed', err);
+ this.snackBar.open('Failed to update user', 'Close', {
+ duration: 4000,
+ panelClass: ['error-snackbar']
+ });
+ }
});
}
}
diff --git a/src/app/users/users.module.ts b/src/app/users/users.module.ts
index 7e1f27107..d04a65ccc 100644
--- a/src/app/users/users.module.ts
+++ b/src/app/users/users.module.ts
@@ -19,6 +19,11 @@ import { CreateUserComponent } from './create-user/create-user.component';
import { ViewUserComponent } from './view-user/view-user.component';
import { EditUserComponent } from './edit-user/edit-user.component';
+/** DSC Components */
+import { DscListComponent } from './dsc/dsc-list/dsc-list.component';
+import { EnrollDscDialogComponent } from './dsc/enroll-dsc-dialog/enroll-dsc-dialog.component';
+import { RevokeDscDialogComponent } from './dsc/revoke-dsc-dialog/revoke-dsc-dialog.component';
+
/** Custom Components of Zitadel */
import { ViewUserComponent as ViewUserZitadelComponent } from '../zitadel/users/view-user/view-user.component';
import { EditUserComponent as EditUserZitadelComponent } from '../zitadel/users/edit-user/edit-user.component';
@@ -38,6 +43,9 @@ import { UsersComponent as UserZitadelComponent } from '../zitadel/users/users.c
CreateUserComponent,
ViewUserComponent,
EditUserComponent,
+ DscListComponent,
+ EnrollDscDialogComponent,
+ RevokeDscDialogComponent,
// Users Zitadel
UserZitadelComponent,
ViewUserZitadelComponent,
diff --git a/src/main.scss b/src/main.scss
index a490be64e..47df0fe3d 100644
--- a/src/main.scss
+++ b/src/main.scss
@@ -1067,3 +1067,18 @@ mat-card {
background: rgb(2 6 23 / 75%) !important;
backdrop-filter: blur(4px);
}
+
+/* ─── DSC Enroll Dialog Panel — edge-to-edge hero ─── */
+.ed-dialog-panel {
+ .mat-mdc-dialog-container {
+ padding: 0;
+ border-radius: 16px;
+ overflow: hidden;
+ }
+
+ .mat-mdc-dialog-surface {
+ padding: 0 !important;
+ border-radius: 16px !important;
+ overflow: hidden !important;
+ }
+}