feat: DSC enrollment + MFA controls, user CRUD UX, auth error hardening
- DSC module: list/enroll/revoke dialogs and dsc.service (m_bank_user_dsc API) - Access control: DSC + MFA toggles via disable-confirm-dialog - Two-factor auth: expanded UX (DSC + OTP flows) - Users: create/edit form polish, success snackbar on edit, DSC widget retained only inside dsc/ (removed from view-user) - Auth: X-Requested-With added globally; error handler null-safe on empty 500 bodies Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
3b752dc6ae
commit
09fab88451
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user