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
@@ -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')) {
<mat-error
>{{ 'labels.inputs.Email' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.invalid' | translate }}</strong></mat-error
@@ -107,7 +107,8 @@ export class CreateUserComponent implements OnInit, AfterViewInit {
'',
[
Validators.required,
Validators.email
Validators.email,
Validators.pattern(/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/)
]
],
mobileNo: [
@@ -169,7 +170,8 @@ export class CreateUserComponent implements OnInit, AfterViewInit {
this.userForm.removeControl('repeatPassword');
this.userForm.get('email').setValidators([
Validators.required,
Validators.email
Validators.email,
Validators.pattern(/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/)
]);
} else {
this.userForm.addControl('password', new UntypedFormControl('', this.passwordsUtility.getPasswordValidators()));
@@ -180,7 +182,10 @@ export class CreateUserComponent implements OnInit, AfterViewInit {
this.passwordsUtility.confirmPassword('password')
])
);
this.userForm.get('email').setValidators([Validators.email]);
this.userForm.get('email').setValidators([
Validators.email,
Validators.pattern(/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/)
]);
}
this.userForm.get('email').updateValueAndValidity();
});
@@ -0,0 +1,107 @@
<!--
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/.
-->
<section class="dsc-card">
<header class="dsc-card__head">
<div class="dsc-card__title">
<fa-icon icon="file-signature" class="dsc-card__title-icon"></fa-icon>
<span>Digital Signatures</span>
</div>
<button mat-flat-button color="primary" class="dsc-card__action" (click)="enroll()">
<fa-icon icon="plus"></fa-icon>
<span>Map New DSC</span>
</button>
</header>
@if (loading) {
<mat-progress-bar mode="indeterminate"></mat-progress-bar>
}
<div class="dsc-card__body">
<table mat-table [dataSource]="dataSource" class="dsc-table">
<ng-container matColumnDef="commonName">
<th mat-header-cell *matHeaderCellDef>Common Name</th>
<td mat-cell *matCellDef="let row">{{ row.commonName || '—' }}</td>
</ng-container>
<ng-container matColumnDef="serial">
<th mat-header-cell *matHeaderCellDef>Serial</th>
<td mat-cell *matCellDef="let row" class="dsc-mono">
{{ row.dscSerialNoFull || row.dscSerialNoMasked || '—' }}
</td>
</ng-container>
<ng-container matColumnDef="issuer">
<th mat-header-cell *matHeaderCellDef>Issuer</th>
<td mat-cell *matCellDef="let row" [matTooltip]="row.issuedBy">
{{ shortenDn(row.issuedBy) || '—' }}
</td>
</ng-container>
<ng-container matColumnDef="expiresOn">
<th mat-header-cell *matHeaderCellDef>Expires</th>
<td mat-cell *matCellDef="let row">{{ formatDate(row.dscExpiryDate) }}</td>
</ng-container>
<ng-container matColumnDef="status">
<th mat-header-cell *matHeaderCellDef>Status</th>
<td mat-cell *matCellDef="let row">
<span class="dsc-pill" [ngClass]="statusClass(row.certStatus)">
<span class="dsc-pill__dot"></span>
{{ statusLabel(row.certStatus) }}
</span>
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef class="dsc-actions-col"></th>
<td mat-cell *matCellDef="let row" class="dsc-actions-col">
<button mat-icon-button [matMenuTriggerFor]="rowMenu" aria-label="DSC actions">
<fa-icon icon="bars"></fa-icon>
</button>
<mat-menu #rowMenu="matMenu">
<button mat-menu-item (click)="view(row)">
<fa-icon icon="eye" class="dsc-menu-icon"></fa-icon>
View details
</button>
<button mat-menu-item (click)="revoke(row)" [disabled]="row.certStatus === 'REVOKED'">
<fa-icon icon="ban" class="dsc-menu-icon dsc-menu-icon--danger"></fa-icon>
Revoke
</button>
</mat-menu>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns"></tr>
</table>
@if (!loading && errorStatus) {
<div class="dsc-state dsc-state--error">
<fa-icon [icon]="errorStatus === 401 || errorStatus === 403 ? 'lock' : 'exclamation-circle'"></fa-icon>
<div>
<strong>
@if (errorStatus === 403) {
Access denied (HTTP 403)
} @else if (errorStatus === 401) {
Sign in required (HTTP 401)
} @else {
Failed to load (HTTP {{ errorStatus }})
}
</strong>
<span>{{ errorMsg }}</span>
</div>
</div>
} @else if (!loading && dataSource.data.length === 0) {
<div class="dsc-state dsc-state--empty">
<fa-icon icon="file-signature"></fa-icon>
<p>No DSC mapped — required only if you want DSC-based two-factor auth.</p>
</div>
}
</div>
</section>
@@ -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;
}
}
}
@@ -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<BankUserDsc>([]);
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;
}
}
+84
View File
@@ -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 <argument> 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;
}
+173
View File
@@ -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<DscEnrollSession> {
return this.http.post<DscEnrollSession>('/v1/dsc/enroll/start', {}, { headers: this.silentHeaders });
}
getEnrollStatus(randomNo: string): Observable<DscChallengeStatus> {
return this.http.get<DscChallengeStatus>(`/v1/dsc/enroll/${encodeURIComponent(randomNo)}/status`);
}
// ── List / detail / revoke ────────────────────────────────────
listMyDscs(): Observable<BankUserDsc[]> {
return this.http.get<BankUserDsc[]>('/v1/dsc/me', { headers: this.silentHeaders });
}
listForUser(userId: number): Observable<BankUserDsc[]> {
return this.http.get<BankUserDsc[]>(`/v1/dsc/user/${userId}`, { headers: this.silentHeaders });
}
getDsc(id: number): Observable<BankUserDsc> {
return this.http.get<BankUserDsc>(`/v1/dsc/${id}`, { headers: this.silentHeaders });
}
revokeDsc(id: number, reason: string): Observable<void> {
const params = new HttpParams().set('reason', reason);
return this.http.delete<void>(`/v1/dsc/${id}`, { params, headers: this.silentHeaders });
}
// ── Login factor ──────────────────────────────────────────────
requestLoginChallenge(): Observable<DscLoginChallenge> {
return this.http.post<DscLoginChallenge>('/v1/login/dsc/challenge', {}, { headers: this.silentHeaders });
}
getLoginStatus(randomNo: string): Observable<DscLoginStatus> {
return this.http.get<DscLoginStatus>(`/v1/login/dsc/${encodeURIComponent(randomNo)}/status`);
}
/** Single-use claim — returns the 2FA bearer token once polling has seen VERIFIED. */
claimLoginToken(randomNo: string): Observable<DscLoginStatus> {
return this.http.post<DscLoginStatus>(
`/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 '<jar href="cbsnexora-dsc-helper.jar">' 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
return `<?xml version="1.0" encoding="utf-8"?>
<jnlp spec="1.0+">
<information>
<vendor>CBSnexora</vendor>
<title>CBSnexora DSC Helper</title>
<description>Reads your DSC for CBSnexora two-factor authentication.</description>
</information>
<security>
<all-permissions/>
</security>
<resources>
<j2se version="1.8+"/>
<jar href="cbsnexora-dsc-helper.jar"/>
</resources>
<application-desc main-class="com.cbsnexora.dsc.CbsnexoraDscHelper">
<argument>${escaped}</argument>
</application-desc>
</jnlp>
`;
}
/**
* 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<T extends { status: string }>(fetcher: () => Observable<T>, timeoutMs: number): Observable<T> {
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))
);
}
}
@@ -0,0 +1,229 @@
<!--
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/.
-->
<!-- Hero header -->
<header class="ed-hero">
<div class="ed-hero__icon">
<fa-icon icon="file-signature"></fa-icon>
</div>
<div class="ed-hero__text">
<h2 class="ed-hero__title">Map a Digital Signature</h2>
<p class="ed-hero__sub">Register your DSC token as a secure way to sign in.</p>
</div>
</header>
<!-- Custom step indicator -->
<div class="ed-steps">
<div class="ed-step" [class.is-active]="stepIndex === 0" [class.is-done]="stepIndex > 0">
<span class="ed-step__circle">
@if (stepIndex > 0) {
<fa-icon icon="check"></fa-icon>
} @else {
1
}
</span>
<span class="ed-step__label">Prepare</span>
</div>
<span class="ed-step__bar" [class.is-active]="stepIndex >= 1"></span>
<div class="ed-step" [class.is-active]="stepIndex === 1" [class.is-done]="stepIndex > 1">
<span class="ed-step__circle">
@if (stepIndex > 1) {
<fa-icon icon="check"></fa-icon>
} @else {
2
}
</span>
<span class="ed-step__label">Detect</span>
</div>
<span class="ed-step__bar" [class.is-active]="stepIndex >= 2"></span>
<div class="ed-step" [class.is-active]="stepIndex === 2" [class.is-done]="stepIndex > 2">
<span class="ed-step__circle">
@if (stepIndex > 2) {
<fa-icon icon="check"></fa-icon>
} @else {
3
}
</span>
<span class="ed-step__label">Confirm</span>
</div>
<span class="ed-step__bar" [class.is-active]="stepIndex >= 3"></span>
<div class="ed-step" [class.is-active]="stepIndex === 3" [class.is-done]="stepIndex === 3">
<span class="ed-step__circle">
@if (stepIndex === 3) {
<fa-icon icon="check"></fa-icon>
} @else {
4
}
</span>
<span class="ed-step__label">Done</span>
</div>
</div>
<mat-dialog-content class="ed-content">
<!-- Hidden Material stepper drives state; custom indicator above renders the visuals. -->
<mat-stepper #stepper [linear]="true" class="ed-stepper-hidden">
<mat-step><ng-template matStepLabel>Prepare</ng-template></mat-step>
<mat-step><ng-template matStepLabel>Detect</ng-template></mat-step>
<mat-step><ng-template matStepLabel>Confirm</ng-template></mat-step>
<mat-step><ng-template matStepLabel>Done</ng-template></mat-step>
</mat-stepper>
<!-- ─────── Step 1: Prepare ─────── -->
@if (stepIndex === 0) {
<p class="ed-intro">A few quick checks before we look for your token.</p>
<div class="ed-checks">
<div class="ed-check">
<div class="ed-check__icon ed-check__icon--blue">
<fa-icon icon="key"></fa-icon>
</div>
<div class="ed-check__body">
<strong>DSC token plugged in</strong>
<span>Insert your USB token into any free port on this machine.</span>
</div>
</div>
<div class="ed-check">
<div class="ed-check__icon ed-check__icon--purple">
<fa-icon icon="cog"></fa-icon>
</div>
<div class="ed-check__body">
<strong>Driver installed</strong>
<span>The token's driver (eMudhra / Sify / Capricorn etc.) is installed.</span>
</div>
</div>
<div class="ed-check">
<div class="ed-check__icon ed-check__icon--green">
<fa-icon icon="download"></fa-icon>
</div>
<div class="ed-check__body">
<strong>OpenWebStart available</strong>
<span>Required to run the helper — Oracle Java Web Start was removed in JDK 11.</span>
</div>
</div>
</div>
<a href="https://openwebstart.com/" target="_blank" rel="noopener" class="ed-callout">
<fa-icon icon="info-circle" class="ed-callout__icon"></fa-icon>
<span class="ed-callout__text">
<strong>Need OpenWebStart?</strong>
Get the free installer for Windows, macOS, or Linux.
</span>
<fa-icon icon="arrow-right" class="ed-callout__chev"></fa-icon>
</a>
}
<!-- ─────── Step 2: Detect ─────── -->
@if (stepIndex === 1) {
@if (detecting) {
<div class="ed-detect">
<div class="ed-detect__ring">
<mat-spinner diameter="64" strokeWidth="4"></mat-spinner>
<fa-icon icon="file-signature" class="ed-detect__inner"></fa-icon>
</div>
<h3 class="ed-detect__title">Waiting for DSC detection…</h3>
<p class="ed-detect__sub">
A helper file was downloaded. If your browser didn't open it automatically, click
<strong>Re-download helper</strong>.
</p>
@if (countdownLabel) {
<span class="ed-pill">
<fa-icon icon="clock"></fa-icon>
{{ countdownLabel }} remaining
</span>
}
<button mat-stroked-button class="ed-redownload" (click)="reDownload()" type="button">
<fa-icon icon="sync-alt"></fa-icon>
Re-download helper
</button>
</div>
}
@if (failed) {
<div class="ed-error">
<div class="ed-error__icon">
<fa-icon icon="exclamation-circle"></fa-icon>
</div>
<h3>Couldn't verify your DSC</h3>
<p>{{ errorMessage }}</p>
<button mat-flat-button color="primary" (click)="retry()" type="button">Try again</button>
</div>
}
}
<!-- ─────── Step 3: Confirm ─────── -->
@if (stepIndex === 2) {
<p class="ed-intro">
<fa-icon icon="check-circle" class="ed-intro__icon ed-intro__icon--ok"></fa-icon>
We detected the following certificate. It's been mapped to your account.
</p>
@if (confirmedDsc) {
<div class="ed-cert">
<div class="ed-cert__row">
<span class="ed-cert__label">Common Name</span>
<span class="ed-cert__value">{{ confirmedDsc.commonName || '—' }}</span>
</div>
<div class="ed-cert__row">
<span class="ed-cert__label">Issued To</span>
<span class="ed-cert__value">{{ confirmedDsc.issuedTo || '—' }}</span>
</div>
<div class="ed-cert__row">
<span class="ed-cert__label">Issued By</span>
<span class="ed-cert__value">{{ confirmedDsc.issuedBy || '—' }}</span>
</div>
<div class="ed-cert__row">
<span class="ed-cert__label">Serial No</span>
<span class="ed-cert__value ed-cert__mono">
{{ confirmedDsc.dscSerialNoFull || confirmedDsc.dscSerialNoMasked || '—' }}
</span>
</div>
<div class="ed-cert__row">
<span class="ed-cert__label">Expires</span>
<span class="ed-cert__value">{{ formatDate(confirmedDsc.dscExpiryDate) }}</span>
</div>
</div>
}
}
<!-- ─────── Step 4: Done ─────── -->
@if (stepIndex === 3) {
<div class="ed-done">
<div class="ed-done__icon">
<fa-icon icon="check-circle"></fa-icon>
</div>
<h3>DSC mapped successfully</h3>
<p>You can now select this certificate as a second factor at sign-in.</p>
</div>
}
</mat-dialog-content>
<mat-dialog-actions align="end" class="ed-actions">
@switch (stepIndex) {
@case (0) {
<button mat-stroked-button (click)="cancel()" type="button">Cancel</button>
<button mat-flat-button color="primary" (click)="startDetection()" type="button">
Continue
<fa-icon icon="arrow-right" class="ed-btn-icon"></fa-icon>
</button>
}
@case (1) {
<button mat-stroked-button (click)="cancel()" type="button">Cancel</button>
}
@case (2) {
<button mat-flat-button color="primary" (click)="next()" type="button">
Continue
<fa-icon icon="arrow-right" class="ed-btn-icon"></fa-icon>
</button>
}
@default {
<button mat-flat-button color="primary" (click)="done()" type="button">Done</button>
}
}
</mat-dialog-actions>
@@ -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;
}
}
@@ -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<EnrollDscDialogComponent>);
@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<void>();
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();
}
}
@@ -0,0 +1,57 @@
<!--
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="revoke-title">
<fa-icon icon="ban" class="revoke-title__icon"></fa-icon>
Revoke Digital Signature
</h2>
<mat-dialog-content class="revoke-content">
<p class="revoke-summary">
You're about to revoke
<strong>{{ data.dsc.commonName || 'this DSC' }}</strong>
(serial {{ data.dsc.dscSerialNoFull || data.dsc.dscSerialNoMasked || '—' }}). This is permanent and the certificate
will no longer work for two-factor authentication.
</p>
<mat-form-field class="revoke-reason" appearance="outline">
<mat-label>Reason</mat-label>
<input
matInput
name="reason"
[(ngModel)]="reason"
[disabled]="submitting"
maxlength="200"
placeholder="e.g. token lost, employee left"
required
/>
@if (!reason?.trim() && submitting === false) {
<mat-error>Reason is required</mat-error>
}
</mat-form-field>
@if (errorMessage) {
<div class="revoke-error">
<fa-icon icon="exclamation-circle"></fa-icon>
{{ errorMessage }}
</div>
}
</mat-dialog-content>
<mat-dialog-actions align="end" class="revoke-actions">
<button mat-stroked-button (click)="cancel()" type="button" [disabled]="submitting">Cancel</button>
<button mat-flat-button color="warn" (click)="confirm()" type="button" [disabled]="!reason?.trim() || submitting">
@if (submitting) {
<mat-spinner diameter="18"></mat-spinner>
<span>Revoking…</span>
} @else {
<fa-icon icon="ban"></fa-icon>
<span>Revoke</span>
}
</button>
</mat-dialog-actions>
@@ -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;
}
}
@@ -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<RevokeDscDialogComponent>);
data = inject<RevokeDialogData>(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.';
}
});
}
}
@@ -81,7 +81,7 @@
<mat-label>{{ 'labels.inputs.Email' | translate }}</mat-label>
<span matPrefix class="eu-prefix-icon"><mat-icon>email</mat-icon></span>
<input matInput required formControlName="email" placeholder="name@example.com" />
@if (editUserForm.controls.email.hasError('email')) {
@if (editUserForm.controls.email.hasError('email') || editUserForm.controls.email.hasError('pattern')) {
<mat-error
>{{ 'labels.inputs.Email' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.invalid' | translate }}</strong></mat-error
+30 -10
View File
@@ -10,6 +10,7 @@
import { Component, OnInit, inject } from '@angular/core';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
/** Custom Services */
import { UsersService } from '../users.service';
@@ -27,7 +28,8 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
imports: [
...STANDALONE_SHARED_IMPORTS,
MatCheckbox,
MatIcon
MatIcon,
MatSnackBarModule
]
})
export class EditUserComponent implements OnInit {
@@ -35,6 +37,7 @@ export class EditUserComponent implements OnInit {
private usersService = inject(UsersService);
private route = inject(ActivatedRoute);
private router = inject(Router);
private snackBar = inject(MatSnackBar);
/** User Data */
userData: any;
@@ -81,7 +84,8 @@ export class EditUserComponent implements OnInit {
this.userData.email,
[
Validators.required,
Validators.email
Validators.email,
Validators.pattern(/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/)
]
],
mobileNo: [
@@ -135,14 +139,30 @@ export class EditUserComponent implements OnInit {
*/
submit() {
const editedUser = this.editUserForm.value;
this.usersService.editUser(this.userData.id, editedUser).subscribe((response: any) => {
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']
});
}
});
}
}
+8
View File
@@ -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,
@@ -12,7 +12,6 @@ import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { MatDialog } from '@angular/material/dialog';
import { MatMenuModule } from '@angular/material/menu';
import { MatIconButton } from '@angular/material/button';
import { MatTooltip } from '@angular/material/tooltip';
/** Custom Services */
import { UsersService } from '../users.service';
@@ -35,8 +34,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
...STANDALONE_SHARED_IMPORTS,
FaIconComponent,
MatMenuModule,
MatIconButton,
MatTooltip
MatIconButton
]
})
export class ViewUserComponent {