merge code feature/UI-DSC-MFA
This commit is contained in:
@@ -817,20 +817,53 @@
|
||||
}
|
||||
|
||||
<div class="ac-hw-info__actions">
|
||||
@if (!currentHwItem.registered) {
|
||||
<button
|
||||
mat-flat-button
|
||||
color="primary"
|
||||
[disabled]="currentHwItem.busy"
|
||||
(click)="registerHardware(currentHwItem)"
|
||||
>
|
||||
<mat-icon>{{ currentHwItem.busy ? 'autorenew' : 'usb' }}</mat-icon>
|
||||
{{ currentHwItem.busy ? 'Detecting device…' : 'Connect & Register' }}
|
||||
</button>
|
||||
@if (currentHwItem.kind === 'digital-signature') {
|
||||
@if (!currentHwItem.registered) {
|
||||
<button
|
||||
mat-flat-button
|
||||
color="primary"
|
||||
[disabled]="currentHwItem.busy"
|
||||
(click)="openDscEnroll(currentHwItem)"
|
||||
>
|
||||
<fa-icon icon="file-signature" class="ac-hw-btn-icon"></fa-icon>
|
||||
Map DSC
|
||||
</button>
|
||||
} @else {
|
||||
<button
|
||||
mat-flat-button
|
||||
color="primary"
|
||||
[disabled]="currentHwItem.mfaEnabled || currentHwItem.busy"
|
||||
(click)="enableDscMfa(currentHwItem)"
|
||||
>
|
||||
<fa-icon icon="check-circle" class="ac-hw-btn-icon"></fa-icon>
|
||||
Enable for 2FA
|
||||
</button>
|
||||
<button
|
||||
mat-stroked-button
|
||||
color="warn"
|
||||
[disabled]="!currentHwItem.mfaEnabled || currentHwItem.busy"
|
||||
(click)="disableDscMfa(currentHwItem)"
|
||||
>
|
||||
<fa-icon icon="ban" class="ac-hw-btn-icon"></fa-icon>
|
||||
Disable for 2FA
|
||||
</button>
|
||||
}
|
||||
} @else {
|
||||
<button mat-stroked-button color="warn" (click)="removeHardware(currentHwItem)">
|
||||
<mat-icon>link_off</mat-icon> Unregister
|
||||
</button>
|
||||
@if (!currentHwItem.registered) {
|
||||
<button
|
||||
mat-flat-button
|
||||
color="primary"
|
||||
[disabled]="currentHwItem.busy"
|
||||
(click)="registerHardware(currentHwItem)"
|
||||
>
|
||||
<mat-icon>{{ currentHwItem.busy ? 'autorenew' : 'usb' }}</mat-icon>
|
||||
{{ currentHwItem.busy ? 'Detecting device…' : 'Connect & Register' }}
|
||||
</button>
|
||||
} @else {
|
||||
<button mat-stroked-button color="warn" (click)="removeHardware(currentHwItem)">
|
||||
<mat-icon>link_off</mat-icon> Unregister
|
||||
</button>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -15,12 +15,14 @@ import { MatFormField, MatLabel, MatPrefix, MatHint, MatError } from '@angular/m
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatInput } from '@angular/material/input';
|
||||
import { MatProgressBar } from '@angular/material/progress-bar';
|
||||
import { DeleteDialogComponent } from 'app/shared/delete-dialog/delete-dialog.component';
|
||||
import { DisableConfirmDialogComponent } from './disable-confirm-dialog/disable-confirm-dialog.component';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { AuthenticationService } from 'app/core/authentication/authentication.service';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
import { UsersService } from 'app/users/users.service';
|
||||
import { AccessControlService, MfaFactor } from './access-control.service';
|
||||
import { DscService } from 'app/users/dsc/dsc.service';
|
||||
import { EnrollDscDialogComponent } from 'app/users/dsc/enroll-dsc-dialog/enroll-dsc-dialog.component';
|
||||
|
||||
type HwSubKind = 'smart-card' | 'digital-signature' | 'biometric';
|
||||
type MethodKey = 'sms' | 'email' | 'totp' | 'hardware';
|
||||
@@ -63,6 +65,8 @@ interface HardwareItem {
|
||||
serial: string;
|
||||
registeredAt: string;
|
||||
busy: boolean;
|
||||
/** DSC-only: whether the mapped DSC is currently enabled as a 2FA factor. */
|
||||
mfaEnabled?: boolean;
|
||||
}
|
||||
|
||||
interface PersistedPrefs {
|
||||
@@ -97,6 +101,7 @@ interface PersistedPrefs {
|
||||
export class AccessControlComponent implements OnInit, OnDestroy {
|
||||
private authenticationService = inject(AuthenticationService);
|
||||
private accessControlService = inject(AccessControlService);
|
||||
private dscService = inject(DscService);
|
||||
private usersService = inject(UsersService);
|
||||
private dialog = inject(MatDialog);
|
||||
|
||||
@@ -182,6 +187,7 @@ export class AccessControlComponent implements OnInit, OnDestroy {
|
||||
if (this.mfaFactorsApiAvailable) {
|
||||
this.hydrateFromServer();
|
||||
}
|
||||
this.hydrateDscState();
|
||||
// If a method is already active, pre-select its radio so the page loads
|
||||
// with the user's current choice highlighted in green.
|
||||
if (this.activeMethod) {
|
||||
@@ -469,13 +475,21 @@ export class AccessControlComponent implements OnInit, OnDestroy {
|
||||
|
||||
disableOtpChannel(channel: OtpChannelState): void {
|
||||
const label = channel.typeCode === 'SMS' ? 'SMS OTP' : 'Email OTP';
|
||||
const destination =
|
||||
channel.typeCode === 'SMS' && channel.destination ? `+91 ${channel.destination}` : channel.destination || '';
|
||||
this.dialog
|
||||
.open(DeleteDialogComponent, {
|
||||
data: { deleteContext: `${label} as your MFA method` }
|
||||
.open(DisableConfirmDialogComponent, {
|
||||
width: '440px',
|
||||
autoFocus: false,
|
||||
data: {
|
||||
title: `Disable ${label}`,
|
||||
message: `${label} will no longer be used as a second factor for sign-in. You can re-enable it later.`,
|
||||
detail: destination ? `Currently active for ${destination}` : ''
|
||||
}
|
||||
})
|
||||
.afterClosed()
|
||||
.subscribe((response: any) => {
|
||||
if (!response?.delete) return;
|
||||
if (!response?.confirmed) return;
|
||||
const finish = () => {
|
||||
channel.verified = false;
|
||||
channel.otp = '';
|
||||
@@ -620,6 +634,58 @@ export class AccessControlComponent implements OnInit, OnDestroy {
|
||||
this.savePrefs();
|
||||
}
|
||||
|
||||
/** Open the DSC enrollment dialog; if it returns success, mark the tile as mapped. */
|
||||
openDscEnroll(item: HardwareItem): void {
|
||||
const ref = this.dialog.open(EnrollDscDialogComponent, {
|
||||
width: '640px',
|
||||
disableClose: true,
|
||||
autoFocus: false,
|
||||
panelClass: 'ed-dialog-panel'
|
||||
});
|
||||
ref.afterClosed().subscribe((result: any) => {
|
||||
if (!result?.enrolled) return;
|
||||
const dsc = result.dsc;
|
||||
item.registered = true;
|
||||
item.serial = dsc?.dscSerialNoFull || dsc?.dscSerialNoMasked || '';
|
||||
item.registeredAt = new Date().toLocaleString();
|
||||
item.mfaEnabled = false;
|
||||
this.savePrefs();
|
||||
});
|
||||
}
|
||||
|
||||
enableDscMfa(item: HardwareItem): void {
|
||||
item.mfaEnabled = true;
|
||||
this.savePrefs();
|
||||
}
|
||||
|
||||
disableDscMfa(item: HardwareItem): void {
|
||||
item.mfaEnabled = false;
|
||||
this.savePrefs();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconcile the DSC tile with the server: if /v1/dsc/me returns at least one ACTIVE
|
||||
* DSC, the tile is considered "registered". Called from ngOnInit alongside the
|
||||
* existing OTP/MFA-factor hydration.
|
||||
*/
|
||||
private hydrateDscState(): void {
|
||||
const dscItem = this.hardware.find((h) => h.kind === 'digital-signature');
|
||||
if (!dscItem) return;
|
||||
this.dscService.listMyDscs().subscribe({
|
||||
next: (dscs) => {
|
||||
const active = (dscs || []).find((d) => (d?.certStatus || '').toUpperCase() === 'ACTIVE');
|
||||
if (active) {
|
||||
dscItem.registered = true;
|
||||
dscItem.serial = active.dscSerialNoMasked || active.dscSerialNoFull || dscItem.serial;
|
||||
dscItem.registeredAt = dscItem.registeredAt || new Date().toLocaleString();
|
||||
}
|
||||
},
|
||||
error: () => {
|
||||
// Silent — keep whatever locally-cached state we have.
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private randomSerial(kind: HwSubKind): string {
|
||||
const prefix = kind === 'smart-card' ? 'SC' : kind === 'digital-signature' ? 'DSC' : 'BIO';
|
||||
let body = '';
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
<!--
|
||||
Copyright since 2025 Mifos Initiative
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<h2 mat-dialog-title class="dcd-title">
|
||||
<fa-icon icon="ban" class="dcd-title__icon"></fa-icon>
|
||||
<span class="dcd-title__text">{{ data.title }}</span>
|
||||
</h2>
|
||||
|
||||
<mat-dialog-content class="dcd-content">
|
||||
<p class="dcd-msg">{{ data.message }}</p>
|
||||
@if (data.detail) {
|
||||
<p class="dcd-detail">{{ data.detail }}</p>
|
||||
}
|
||||
</mat-dialog-content>
|
||||
|
||||
<mat-dialog-actions align="end" class="dcd-actions">
|
||||
<button mat-stroked-button type="button" (click)="cancel()">Cancel</button>
|
||||
<button mat-flat-button color="warn" type="button" (click)="confirm()">
|
||||
<fa-icon icon="ban" class="dcd-btn-icon"></fa-icon>
|
||||
<span>Disable</span>
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
min-width: 380px;
|
||||
max-width: 480px;
|
||||
}
|
||||
|
||||
.dcd-title {
|
||||
display: flex !important;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin: 0 !important;
|
||||
padding: 16px 24px 4px !important;
|
||||
font-size: 1.05rem !important;
|
||||
font-weight: 600 !important;
|
||||
color: #b91c1c !important;
|
||||
|
||||
&__icon {
|
||||
color: #dc2626;
|
||||
font-size: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__text {
|
||||
color: #b91c1c;
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.dcd-content {
|
||||
padding: 8px 24px 16px !important;
|
||||
color: #334155 !important;
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
.dcd-msg {
|
||||
margin: 0 0 8px !important;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.5;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.dcd-detail {
|
||||
margin: 0 !important;
|
||||
font-size: 0.85rem;
|
||||
color: #64748b;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.dcd-actions {
|
||||
padding: 8px 20px 16px !important;
|
||||
gap: 8px;
|
||||
|
||||
button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.dcd-btn-icon {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Component, inject } from '@angular/core';
|
||||
import {
|
||||
MAT_DIALOG_DATA,
|
||||
MatDialogRef,
|
||||
MatDialogTitle,
|
||||
MatDialogContent,
|
||||
MatDialogActions
|
||||
} from '@angular/material/dialog';
|
||||
import { MatButton } from '@angular/material/button';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
|
||||
export interface DisableConfirmDialogData {
|
||||
/** Heading shown at the top, e.g. "Disable SMS OTP". */
|
||||
title: string;
|
||||
/** Body paragraph explaining the impact. */
|
||||
message: string;
|
||||
/** Optional muted line shown below the message (the destination being disabled). */
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-disable-confirm-dialog',
|
||||
templateUrl: './disable-confirm-dialog.component.html',
|
||||
styleUrls: ['./disable-confirm-dialog.component.scss'],
|
||||
imports: [
|
||||
MatDialogTitle,
|
||||
MatDialogContent,
|
||||
MatDialogActions,
|
||||
MatButton,
|
||||
FaIconComponent
|
||||
]
|
||||
})
|
||||
export class DisableConfirmDialogComponent {
|
||||
data = inject<DisableConfirmDialogData>(MAT_DIALOG_DATA);
|
||||
private dialogRef = inject<MatDialogRef<DisableConfirmDialogComponent>>(MatDialogRef);
|
||||
|
||||
cancel(): void {
|
||||
this.dialogRef.close({ confirmed: false });
|
||||
}
|
||||
|
||||
confirm(): void {
|
||||
this.dialogRef.close({ confirmed: true });
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,10 @@ import { SettingsService } from 'app/settings/settings.service';
|
||||
/** Http request (default) options headers. */
|
||||
const httpOptions: { headers: { [key: string]: string } } = {
|
||||
headers: {
|
||||
'Fineract-Platform-TenantId': environment.fineractPlatformTenantId
|
||||
'Fineract-Platform-TenantId': environment.fineractPlatformTenantId,
|
||||
// Suppresses Spring's WWW-Authenticate: Basic header on 401, which prevents
|
||||
// the browser's native sign-in dialog from hijacking expired-session responses.
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -45,11 +45,10 @@ export class ErrorHandlerInterceptor implements HttpInterceptor {
|
||||
*/
|
||||
private handleError(response: HttpErrorResponse, request: HttpRequest<any>): Observable<HttpEvent<any>> {
|
||||
const status = response.status;
|
||||
let errorMessage = response.error.developerMessage || response.message;
|
||||
if (response.error.errors) {
|
||||
if (response.error.errors[0]) {
|
||||
errorMessage = response.error.errors[0].defaultUserMessage || response.error.errors[0].developerMessage;
|
||||
}
|
||||
const errorBody = response.error;
|
||||
let errorMessage = errorBody?.developerMessage || response.message;
|
||||
if (errorBody?.errors?.[0]) {
|
||||
errorMessage = errorBody.errors[0].defaultUserMessage || errorBody.errors[0].developerMessage;
|
||||
}
|
||||
|
||||
const isClientImageRequest = request.url.includes('/clients/') && request.url.includes('/images');
|
||||
|
||||
@@ -13,16 +13,22 @@
|
||||
|
||||
<h2 class="tfa-title">{{ 'labels.text.Two Factor Authentication' | translate }}</h2>
|
||||
<p class="tfa-sub">
|
||||
@if (!otpRequested) {
|
||||
Choose how you want to receive your one-time passcode.
|
||||
@if (dscChallengeActive) {
|
||||
Plug in your DSC token and follow the prompts in the helper.
|
||||
} @else if (!otpRequested) {
|
||||
Choose how you want to verify your identity.
|
||||
} @else {
|
||||
Enter the 6-digit code we sent you to continue.
|
||||
}
|
||||
</p>
|
||||
|
||||
<!-- Step 1 — Pick a delivery method -->
|
||||
@if (!otpRequested) {
|
||||
<form class="tfa-form" [formGroup]="twoFactorAuthenticationDeliveryMethodForm" (ngSubmit)="requestOTP()">
|
||||
@if (!otpRequested && !dscChallengeActive) {
|
||||
<form
|
||||
class="tfa-form"
|
||||
[formGroup]="twoFactorAuthenticationDeliveryMethodForm"
|
||||
(ngSubmit)="continueWithSelectedFactor()"
|
||||
>
|
||||
<div class="tfa-methods" formGroupName="">
|
||||
<mat-radio-group class="tfa-methods__group" formControlName="twoFactorAuthenticationDeliveryMethod">
|
||||
@for (method of twoFactorAuthenticationDeliveryMethods; track method.name) {
|
||||
@@ -46,17 +52,17 @@
|
||||
(click)="$event.stopPropagation()"
|
||||
></mat-radio-button>
|
||||
<div class="tfa-method__icon">
|
||||
<fa-icon
|
||||
[icon]="
|
||||
(method.name | lowercase) === 'sms' || (method.name | lowercase) === 'mobile'
|
||||
? 'mobile-alt'
|
||||
: 'envelope'
|
||||
"
|
||||
></fa-icon>
|
||||
<fa-icon [icon]="iconFor(method)"></fa-icon>
|
||||
</div>
|
||||
<div class="tfa-method__body">
|
||||
<span class="tfa-method__name">{{ method.name | titlecase }}</span>
|
||||
<span class="tfa-method__target">{{ maskTarget(method) }}</span>
|
||||
<span class="tfa-method__target">
|
||||
@if (method.kind === 'DSC') {
|
||||
{{ method.target }}
|
||||
} @else {
|
||||
{{ maskTarget(method) }}
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -73,23 +79,66 @@
|
||||
<span>Sending…</span>
|
||||
} @else {
|
||||
<fa-icon icon="paper-plane"></fa-icon>
|
||||
<span>{{ 'labels.buttons.Request OTP' | translate }}</span>
|
||||
<span>Continue</span>
|
||||
}
|
||||
</button>
|
||||
</form>
|
||||
}
|
||||
|
||||
<!-- DSC challenge sub-screen — replaces the OTP input when DSC was picked. -->
|
||||
@if (dscChallengeActive) {
|
||||
<div class="tfa-context tfa-context--dsc">
|
||||
<fa-icon icon="file-signature"></fa-icon>
|
||||
<div>
|
||||
<span class="tfa-context__label">Signing as</span>
|
||||
<strong class="tfa-context__target">{{ selectedTwoFactorAuthenticationDeliveryMethod?.target }}</strong>
|
||||
</div>
|
||||
@if (dscCountdownLabel) {
|
||||
<span class="tfa-context__validity">
|
||||
<fa-icon icon="clock"></fa-icon>
|
||||
{{ dscCountdownLabel }} remaining
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="tfa-dsc-hint">
|
||||
<fa-icon icon="info-circle"></fa-icon>
|
||||
<span>
|
||||
A verification helper has been downloaded. If your browser didn't open it, click
|
||||
<strong>Re-download helper</strong>. Need OpenWebStart?
|
||||
<a href="https://openwebstart.com/" target="_blank" rel="noopener">Install it</a>.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@if (dscDetecting) {
|
||||
<div class="tfa-dsc-spinner">
|
||||
<mat-spinner diameter="36"></mat-spinner>
|
||||
<span>Waiting for DSC verification…</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (dscFailed) {
|
||||
<div class="tfa-error">
|
||||
<fa-icon icon="exclamation-circle"></fa-icon>
|
||||
{{ dscErrorMessage }}
|
||||
</div>
|
||||
}
|
||||
|
||||
<button type="button" class="tfa-btn tfa-btn--ghost" (click)="reDownloadDscHelper()" [disabled]="loading">
|
||||
<fa-icon icon="sync-alt"></fa-icon>
|
||||
<span>Re-download helper</span>
|
||||
</button>
|
||||
|
||||
<button type="button" class="tfa-btn tfa-btn--ghost" (click)="useAnotherMethod()" [disabled]="loading">
|
||||
<fa-icon icon="arrow-left"></fa-icon>
|
||||
<span>Use another method</span>
|
||||
</button>
|
||||
}
|
||||
|
||||
<!-- Step 2 — Enter the code -->
|
||||
@if (otpRequested) {
|
||||
@if (otpRequested && !dscChallengeActive) {
|
||||
<div class="tfa-context">
|
||||
<fa-icon
|
||||
[icon]="
|
||||
(selectedTwoFactorAuthenticationDeliveryMethod.name | lowercase) === 'sms' ||
|
||||
(selectedTwoFactorAuthenticationDeliveryMethod.name | lowercase) === 'mobile'
|
||||
? 'mobile-alt'
|
||||
: 'envelope'
|
||||
"
|
||||
></fa-icon>
|
||||
<fa-icon [icon]="iconFor(selectedTwoFactorAuthenticationDeliveryMethod)"></fa-icon>
|
||||
<div>
|
||||
<span class="tfa-context__label">Code sent to</span>
|
||||
<strong class="tfa-context__target">{{ maskTarget(selectedTwoFactorAuthenticationDeliveryMethod) }}</strong>
|
||||
|
||||
@@ -154,6 +154,53 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* ──────── DSC challenge sub-screen ──────── */
|
||||
.tfa-context--dsc {
|
||||
> fa-icon {
|
||||
color: #a78bfa;
|
||||
}
|
||||
}
|
||||
|
||||
.tfa-dsc-hint {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 12px 14px;
|
||||
margin-bottom: 14px;
|
||||
border-radius: 10px;
|
||||
background: rgb(59 130 246 / 12%);
|
||||
border: 1px solid rgb(59 130 246 / 25%);
|
||||
color: rgb(255 255 255 / 88%);
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
|
||||
> fa-icon {
|
||||
color: #93c5fd;
|
||||
margin-top: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
strong {
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #93c5fd;
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.tfa-dsc-spinner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 18px 0 8px;
|
||||
color: rgb(255 255 255 / 78%);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* ──────── Step 2 — context strip ──────── */
|
||||
.tfa-context {
|
||||
display: flex;
|
||||
|
||||
@@ -7,14 +7,17 @@
|
||||
*/
|
||||
|
||||
/** Angular Imports */
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { Component, OnDestroy, OnInit, inject } from '@angular/core';
|
||||
import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
|
||||
|
||||
/** rxjs Imports */
|
||||
import { finalize } from 'rxjs/operators';
|
||||
import { Subject, Subscription, interval, takeUntil } from 'rxjs';
|
||||
|
||||
/** Custom Services */
|
||||
import { AuthenticationService } from '../../core/authentication/authentication.service';
|
||||
import { DscService } from '../../users/dsc/dsc.service';
|
||||
import { BankUserDsc, DscLoginChallenge } from '../../users/dsc/dsc.model';
|
||||
import { MatRadioGroup, MatRadioButton } from '@angular/material/radio';
|
||||
import { MatProgressSpinner } from '@angular/material/progress-spinner';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
@@ -35,9 +38,10 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
FaIconComponent
|
||||
]
|
||||
})
|
||||
export class TwoFactorAuthenticationComponent implements OnInit {
|
||||
export class TwoFactorAuthenticationComponent implements OnInit, OnDestroy {
|
||||
private formBuilder = inject(UntypedFormBuilder);
|
||||
private authenticationService = inject(AuthenticationService);
|
||||
private dscService = inject(DscService);
|
||||
|
||||
/** Available delivery methods to receive OTP. */
|
||||
twoFactorAuthenticationDeliveryMethods: any;
|
||||
@@ -56,6 +60,21 @@ export class TwoFactorAuthenticationComponent implements OnInit {
|
||||
/** True if loading. */
|
||||
resendOTPLoading = false;
|
||||
|
||||
/** True once the user picked DSC and the challenge sub-screen is active. */
|
||||
dscChallengeActive = false;
|
||||
/** Currently-running DSC login challenge (set by requestDscChallenge). */
|
||||
dscChallenge: DscLoginChallenge | null = null;
|
||||
/** Status flags for the DSC challenge sub-screen. */
|
||||
dscDetecting = false;
|
||||
dscFailed = false;
|
||||
dscErrorMessage = '';
|
||||
/** Seconds remaining on the DSC challenge — driven off server response. */
|
||||
dscSecondsRemaining = 0;
|
||||
dscCountdownLabel = '';
|
||||
|
||||
private dscPollSub?: Subscription;
|
||||
private destroy$ = new Subject<void>();
|
||||
|
||||
/**
|
||||
* Creates two factor authentication delivery method form.
|
||||
*
|
||||
@@ -63,22 +82,68 @@ export class TwoFactorAuthenticationComponent implements OnInit {
|
||||
*/
|
||||
ngOnInit() {
|
||||
this.createTwoFactorAuthenticationDeliveryMethodForm();
|
||||
this.authenticationService.getDeliveryMethods().subscribe((deliveryMethods: any) => {
|
||||
// Keep only methods the user has actually configured (target populated).
|
||||
// Methods returned without a destination are noise from the backend.
|
||||
const enabled = (deliveryMethods || []).filter((m: any) => !!m?.target);
|
||||
this.twoFactorAuthenticationDeliveryMethods = enabled;
|
||||
|
||||
// If exactly one method is enabled, skip the picker and request OTP automatically.
|
||||
if (enabled.length === 1) {
|
||||
this.twoFactorAuthenticationDeliveryMethodForm
|
||||
.get('twoFactorAuthenticationDeliveryMethod')
|
||||
?.setValue(enabled[0]);
|
||||
this.requestOTP();
|
||||
// Fetch SMS/EMAIL delivery methods and the user's ACTIVE DSCs in parallel,
|
||||
// merge them into a single factor list, then auto-route if there's only one.
|
||||
this.authenticationService.getDeliveryMethods().subscribe({
|
||||
next: (deliveryMethods: any) => {
|
||||
const enabled = (deliveryMethods || []).filter((m: any) => !!m?.target);
|
||||
this.dscService.listMyDscs().subscribe({
|
||||
next: (dscs) => this.mergeFactors(enabled, dscs || []),
|
||||
error: () => this.mergeFactors(enabled, [])
|
||||
});
|
||||
},
|
||||
error: () => {
|
||||
// If delivery-methods call fails, still try to surface DSC.
|
||||
this.dscService.listMyDscs().subscribe({
|
||||
next: (dscs) => this.mergeFactors([], dscs || []),
|
||||
error: () => this.mergeFactors([], [])
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
this.dscPollSub?.unsubscribe();
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge OTP delivery methods with ACTIVE DSCs into a single list of factors.
|
||||
* DSC factors carry `kind: 'DSC'` so the picker and Continue button can branch.
|
||||
*/
|
||||
private mergeFactors(otpMethods: any[], dscs: BankUserDsc[]): void {
|
||||
const dscFactors = (dscs || [])
|
||||
.filter((d) => (d?.certStatus || '').toUpperCase() === 'ACTIVE')
|
||||
.map((d) => ({
|
||||
kind: 'DSC',
|
||||
name: 'DSC',
|
||||
target: d.commonName || d.dscName || d.dscSerialNoMasked || `DSC #${d.id}`,
|
||||
dsc: d
|
||||
}));
|
||||
const combined = [
|
||||
...otpMethods.map((m) => ({ ...m, kind: 'OTP' })),
|
||||
...dscFactors
|
||||
];
|
||||
this.twoFactorAuthenticationDeliveryMethods = combined;
|
||||
if (combined.length === 1) {
|
||||
this.twoFactorAuthenticationDeliveryMethodForm
|
||||
.get('twoFactorAuthenticationDeliveryMethod')
|
||||
?.setValue(combined[0]);
|
||||
this.continueWithSelectedFactor();
|
||||
}
|
||||
}
|
||||
|
||||
/** Branches on the picked factor: OTP path vs DSC challenge path. */
|
||||
continueWithSelectedFactor(): void {
|
||||
const method = this.twoFactorAuthenticationDeliveryMethodForm.value.twoFactorAuthenticationDeliveryMethod;
|
||||
if (method?.kind === 'DSC') {
|
||||
this.requestDscChallenge(method);
|
||||
} else {
|
||||
this.requestOTP();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests OTP via the selected delivery method.
|
||||
*/
|
||||
@@ -131,6 +196,149 @@ export class TwoFactorAuthenticationComponent implements OnInit {
|
||||
this.twoFactorAuthenticationDeliveryMethodForm.get('twoFactorAuthenticationDeliveryMethod')?.setValue(method);
|
||||
}
|
||||
|
||||
/** Icon name for a factor row in the picker / context strip. */
|
||||
iconFor(method: any): string {
|
||||
const name = (method?.name || '').toLowerCase();
|
||||
if (method?.kind === 'DSC' || name === 'dsc') return 'signature';
|
||||
if (name === 'sms' || name === 'mobile' || name === 'phone') return 'mobile-alt';
|
||||
return 'envelope';
|
||||
}
|
||||
|
||||
/** Start a DSC login challenge: POST /challenge → fire JNLP → begin polling. */
|
||||
requestDscChallenge(method: any): void {
|
||||
this.loading = true;
|
||||
this.dscFailed = false;
|
||||
this.dscErrorMessage = '';
|
||||
this.dscChallengeActive = true;
|
||||
this.selectedTwoFactorAuthenticationDeliveryMethod = method;
|
||||
this.twoFactorAuthenticationDeliveryMethodForm.disable();
|
||||
|
||||
this.dscService
|
||||
.requestLoginChallenge()
|
||||
.pipe(finalize(() => (this.loading = false)))
|
||||
.subscribe({
|
||||
next: (challenge) => {
|
||||
this.dscChallenge = challenge;
|
||||
this.dscSecondsRemaining = challenge.expiresInSeconds || 120;
|
||||
this.dscDetecting = true;
|
||||
this.tickDscCountdown();
|
||||
// Pass the whole challenge — the service builds the JNLP locally and bypasses
|
||||
// JWS server fetches entirely.
|
||||
this.dscService.downloadAndOpenJnlp(challenge);
|
||||
this.beginDscPolling();
|
||||
},
|
||||
error: (err) => {
|
||||
this.dscDetecting = false;
|
||||
this.dscFailed = true;
|
||||
this.dscErrorMessage =
|
||||
err?.error?.defaultUserMessage ||
|
||||
err?.error?.message ||
|
||||
err?.message ||
|
||||
'Could not start DSC verification.';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Re-fire the JNLP without restarting the challenge. */
|
||||
reDownloadDscHelper(): void {
|
||||
if (this.dscChallenge) this.dscService.downloadAndOpenJnlp(this.dscChallenge);
|
||||
}
|
||||
|
||||
/** Drop back to the factor picker without consuming the challenge. */
|
||||
useAnotherMethod(): void {
|
||||
this.dscPollSub?.unsubscribe();
|
||||
this.dscChallengeActive = false;
|
||||
this.dscChallenge = null;
|
||||
this.dscDetecting = false;
|
||||
this.dscFailed = false;
|
||||
this.dscErrorMessage = '';
|
||||
this.selectedTwoFactorAuthenticationDeliveryMethod = null;
|
||||
this.twoFactorAuthenticationDeliveryMethodForm.enable();
|
||||
this.twoFactorAuthenticationDeliveryMethodForm.reset();
|
||||
}
|
||||
|
||||
private beginDscPolling(): void {
|
||||
if (!this.dscChallenge?.randomNo) return;
|
||||
const timeoutMs = (this.dscChallenge.expiresInSeconds || 120) * 1000;
|
||||
this.dscPollSub?.unsubscribe();
|
||||
this.dscPollSub = this.dscService
|
||||
.pollStatus(() => this.dscService.getLoginStatus(this.dscChallenge!.randomNo), timeoutMs)
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe({
|
||||
next: (res) => {
|
||||
if (res.status === 'VERIFIED') {
|
||||
this.dscDetecting = false;
|
||||
this.claimAndValidate(this.dscChallenge!.randomNo);
|
||||
} else if (res.status === 'FAILED' || res.status === 'EXPIRED') {
|
||||
this.dscDetecting = false;
|
||||
this.dscFailed = true;
|
||||
this.dscErrorMessage =
|
||||
(res as any).errorMessage ||
|
||||
(res.status === 'EXPIRED' ? 'Verification took too long — try again.' : 'DSC verification failed.');
|
||||
}
|
||||
},
|
||||
complete: () => {
|
||||
if (this.dscDetecting) {
|
||||
this.dscDetecting = false;
|
||||
this.dscFailed = true;
|
||||
this.dscErrorMessage = 'Verification took too long — try again.';
|
||||
}
|
||||
},
|
||||
error: (err) => {
|
||||
this.dscDetecting = false;
|
||||
this.dscFailed = true;
|
||||
this.dscErrorMessage = err?.error?.defaultUserMessage || err?.message || 'Polling failed.';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** After /status reports VERIFIED, fetch the TFA token via /claim and finalise login. */
|
||||
private claimAndValidate(randomNo: string): void {
|
||||
this.loading = true;
|
||||
this.dscService
|
||||
.claimLoginToken(randomNo)
|
||||
.pipe(finalize(() => (this.loading = false)))
|
||||
.subscribe({
|
||||
next: (res: any) => {
|
||||
const token: string | null = res?.tfaToken || null;
|
||||
if (!token) {
|
||||
this.dscFailed = true;
|
||||
this.dscErrorMessage =
|
||||
res?.errorMessage || 'DSC verified, but the verification token could not be claimed.';
|
||||
return;
|
||||
}
|
||||
// Reuse the existing /twofactor/validate pipeline; backend treats the
|
||||
// DSC-issued bearer token the same as an OTP code.
|
||||
this.authenticationService.validateOTP(token).subscribe({
|
||||
error: (err) => {
|
||||
this.dscFailed = true;
|
||||
this.dscErrorMessage =
|
||||
err?.error?.defaultUserMessage || err?.error?.message || err?.message || 'Token rejected by server.';
|
||||
}
|
||||
});
|
||||
},
|
||||
error: (err) => {
|
||||
this.dscFailed = true;
|
||||
this.dscErrorMessage =
|
||||
err?.error?.defaultUserMessage ||
|
||||
err?.error?.message ||
|
||||
err?.message ||
|
||||
'Could not claim verification token.';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private tickDscCountdown(): void {
|
||||
interval(1000)
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe(() => {
|
||||
if (this.dscSecondsRemaining > 0) this.dscSecondsRemaining--;
|
||||
const m = Math.floor(this.dscSecondsRemaining / 60);
|
||||
const s = this.dscSecondsRemaining % 60;
|
||||
this.dscCountdownLabel = `${m}:${String(s).padStart(2, '0')}`;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Mask the delivery target so only the last 4 characters are visible.
|
||||
* Mobile → "+XXXXXX2076"; Email → "ad***@gmail.com".
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
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
|
||||
|
||||
@@ -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']
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user