diff --git a/src/app/access-control/access-control.component.html b/src/app/access-control/access-control.component.html index c8b1dcec3..191b3e53e 100644 --- a/src/app/access-control/access-control.component.html +++ b/src/app/access-control/access-control.component.html @@ -817,20 +817,53 @@ }
- @if (!currentHwItem.registered) { - + @if (currentHwItem.kind === 'digital-signature') { + @if (!currentHwItem.registered) { + + } @else { + + + } } @else { - + @if (!currentHwItem.registered) { + + } @else { + + } }
diff --git a/src/app/access-control/access-control.component.ts b/src/app/access-control/access-control.component.ts index 16ebb78ee..2964b098e 100644 --- a/src/app/access-control/access-control.component.ts +++ b/src/app/access-control/access-control.component.ts @@ -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 = ''; diff --git a/src/app/access-control/disable-confirm-dialog/disable-confirm-dialog.component.html b/src/app/access-control/disable-confirm-dialog/disable-confirm-dialog.component.html new file mode 100644 index 000000000..9662673c7 --- /dev/null +++ b/src/app/access-control/disable-confirm-dialog/disable-confirm-dialog.component.html @@ -0,0 +1,27 @@ + + +

+ + {{ data.title }} +

+ + +

{{ data.message }}

+ @if (data.detail) { +

{{ data.detail }}

+ } +
+ + + + + diff --git a/src/app/access-control/disable-confirm-dialog/disable-confirm-dialog.component.scss b/src/app/access-control/disable-confirm-dialog/disable-confirm-dialog.component.scss new file mode 100644 index 000000000..aea6d6440 --- /dev/null +++ b/src/app/access-control/disable-confirm-dialog/disable-confirm-dialog.component.scss @@ -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; + } +} diff --git a/src/app/access-control/disable-confirm-dialog/disable-confirm-dialog.component.ts b/src/app/access-control/disable-confirm-dialog/disable-confirm-dialog.component.ts new file mode 100644 index 000000000..c91bb4996 --- /dev/null +++ b/src/app/access-control/disable-confirm-dialog/disable-confirm-dialog.component.ts @@ -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(MAT_DIALOG_DATA); + private dialogRef = inject>(MatDialogRef); + + cancel(): void { + this.dialogRef.close({ confirmed: false }); + } + + confirm(): void { + this.dialogRef.close({ confirmed: true }); + } +} diff --git a/src/app/core/authentication/authentication.interceptor.ts b/src/app/core/authentication/authentication.interceptor.ts index 7834d499a..8a1339ceb 100644 --- a/src/app/core/authentication/authentication.interceptor.ts +++ b/src/app/core/authentication/authentication.interceptor.ts @@ -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' } }; diff --git a/src/app/core/http/error-handler.interceptor.ts b/src/app/core/http/error-handler.interceptor.ts index e84714c68..d1d3e0db5 100644 --- a/src/app/core/http/error-handler.interceptor.ts +++ b/src/app/core/http/error-handler.interceptor.ts @@ -45,11 +45,10 @@ export class ErrorHandlerInterceptor implements HttpInterceptor { */ private handleError(response: HttpErrorResponse, request: HttpRequest): Observable> { 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'); diff --git a/src/app/login/two-factor-authentication/two-factor-authentication.component.html b/src/app/login/two-factor-authentication/two-factor-authentication.component.html index 6122beb42..95663db2d 100644 --- a/src/app/login/two-factor-authentication/two-factor-authentication.component.html +++ b/src/app/login/two-factor-authentication/two-factor-authentication.component.html @@ -13,16 +13,22 @@

{{ 'labels.text.Two Factor Authentication' | translate }}

- @if (!otpRequested) { - Choose how you want to receive your one-time passcode. + @if (dscChallengeActive) { + Plug in your DSC token and follow the prompts in the helper. + } @else if (!otpRequested) { + Choose how you want to verify your identity. } @else { Enter the 6-digit code we sent you to continue. }

- @if (!otpRequested) { -
+ @if (!otpRequested && !dscChallengeActive) { +
@for (method of twoFactorAuthenticationDeliveryMethods; track method.name) { @@ -46,17 +52,17 @@ (click)="$event.stopPropagation()" >
- +
{{ method.name | titlecase }} - {{ maskTarget(method) }} + + @if (method.kind === 'DSC') { + {{ method.target }} + } @else { + {{ maskTarget(method) }} + } +
} @@ -73,23 +79,66 @@ Sending… } @else { - {{ 'labels.buttons.Request OTP' | translate }} + Continue }
} + + @if (dscChallengeActive) { +
+ +
+ Signing as + {{ selectedTwoFactorAuthenticationDeliveryMethod?.target }} +
+ @if (dscCountdownLabel) { + + + {{ dscCountdownLabel }} remaining + + } +
+ +
+ + + A verification helper has been downloaded. If your browser didn't open it, click + Re-download helper. Need OpenWebStart? + Install it. + +
+ + @if (dscDetecting) { +
+ + Waiting for DSC verification… +
+ } + + @if (dscFailed) { +
+ + {{ dscErrorMessage }} +
+ } + + + + + } + - @if (otpRequested) { + @if (otpRequested && !dscChallengeActive) {
- +
Code sent to {{ maskTarget(selectedTwoFactorAuthenticationDeliveryMethod) }} diff --git a/src/app/login/two-factor-authentication/two-factor-authentication.component.scss b/src/app/login/two-factor-authentication/two-factor-authentication.component.scss index 1728e0d4d..e76036d5f 100644 --- a/src/app/login/two-factor-authentication/two-factor-authentication.component.scss +++ b/src/app/login/two-factor-authentication/two-factor-authentication.component.scss @@ -154,6 +154,53 @@ } } +/* ──────── DSC challenge sub-screen ──────── */ +.tfa-context--dsc { + > fa-icon { + color: #a78bfa; + } +} + +.tfa-dsc-hint { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 12px 14px; + margin-bottom: 14px; + border-radius: 10px; + background: rgb(59 130 246 / 12%); + border: 1px solid rgb(59 130 246 / 25%); + color: rgb(255 255 255 / 88%); + font-size: 13px; + line-height: 1.45; + + > fa-icon { + color: #93c5fd; + margin-top: 2px; + flex-shrink: 0; + } + + strong { + color: #fff; + font-weight: 600; + } + + a { + color: #93c5fd; + text-decoration: underline; + } +} + +.tfa-dsc-spinner { + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; + padding: 18px 0 8px; + color: rgb(255 255 255 / 78%); + font-size: 13px; +} + /* ──────── Step 2 — context strip ──────── */ .tfa-context { display: flex; diff --git a/src/app/login/two-factor-authentication/two-factor-authentication.component.ts b/src/app/login/two-factor-authentication/two-factor-authentication.component.ts index 1e05cf143..768cda2cd 100644 --- a/src/app/login/two-factor-authentication/two-factor-authentication.component.ts +++ b/src/app/login/two-factor-authentication/two-factor-authentication.component.ts @@ -7,14 +7,17 @@ */ /** Angular Imports */ -import { Component, OnInit, inject } from '@angular/core'; +import { Component, OnDestroy, OnInit, inject } from '@angular/core'; import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms'; /** rxjs Imports */ import { finalize } from 'rxjs/operators'; +import { Subject, Subscription, interval, takeUntil } from 'rxjs'; /** Custom Services */ import { AuthenticationService } from '../../core/authentication/authentication.service'; +import { DscService } from '../../users/dsc/dsc.service'; +import { BankUserDsc, DscLoginChallenge } from '../../users/dsc/dsc.model'; import { MatRadioGroup, MatRadioButton } from '@angular/material/radio'; import { MatProgressSpinner } from '@angular/material/progress-spinner'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; @@ -35,9 +38,10 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; FaIconComponent ] }) -export class TwoFactorAuthenticationComponent implements OnInit { +export class TwoFactorAuthenticationComponent implements OnInit, OnDestroy { private formBuilder = inject(UntypedFormBuilder); private authenticationService = inject(AuthenticationService); + private dscService = inject(DscService); /** Available delivery methods to receive OTP. */ twoFactorAuthenticationDeliveryMethods: any; @@ -56,6 +60,21 @@ export class TwoFactorAuthenticationComponent implements OnInit { /** True if loading. */ resendOTPLoading = false; + /** True once the user picked DSC and the challenge sub-screen is active. */ + dscChallengeActive = false; + /** Currently-running DSC login challenge (set by requestDscChallenge). */ + dscChallenge: DscLoginChallenge | null = null; + /** Status flags for the DSC challenge sub-screen. */ + dscDetecting = false; + dscFailed = false; + dscErrorMessage = ''; + /** Seconds remaining on the DSC challenge — driven off server response. */ + dscSecondsRemaining = 0; + dscCountdownLabel = ''; + + private dscPollSub?: Subscription; + private destroy$ = new Subject(); + /** * Creates two factor authentication delivery method form. * @@ -63,22 +82,68 @@ export class TwoFactorAuthenticationComponent implements OnInit { */ ngOnInit() { this.createTwoFactorAuthenticationDeliveryMethodForm(); - this.authenticationService.getDeliveryMethods().subscribe((deliveryMethods: any) => { - // Keep only methods the user has actually configured (target populated). - // Methods returned without a destination are noise from the backend. - const enabled = (deliveryMethods || []).filter((m: any) => !!m?.target); - this.twoFactorAuthenticationDeliveryMethods = enabled; - - // If exactly one method is enabled, skip the picker and request OTP automatically. - if (enabled.length === 1) { - this.twoFactorAuthenticationDeliveryMethodForm - .get('twoFactorAuthenticationDeliveryMethod') - ?.setValue(enabled[0]); - this.requestOTP(); + // Fetch SMS/EMAIL delivery methods and the user's ACTIVE DSCs in parallel, + // merge them into a single factor list, then auto-route if there's only one. + this.authenticationService.getDeliveryMethods().subscribe({ + next: (deliveryMethods: any) => { + const enabled = (deliveryMethods || []).filter((m: any) => !!m?.target); + this.dscService.listMyDscs().subscribe({ + next: (dscs) => this.mergeFactors(enabled, dscs || []), + error: () => this.mergeFactors(enabled, []) + }); + }, + error: () => { + // If delivery-methods call fails, still try to surface DSC. + this.dscService.listMyDscs().subscribe({ + next: (dscs) => this.mergeFactors([], dscs || []), + error: () => this.mergeFactors([], []) + }); } }); } + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + this.dscPollSub?.unsubscribe(); + } + + /** + * Merge OTP delivery methods with ACTIVE DSCs into a single list of factors. + * DSC factors carry `kind: 'DSC'` so the picker and Continue button can branch. + */ + private mergeFactors(otpMethods: any[], dscs: BankUserDsc[]): void { + const dscFactors = (dscs || []) + .filter((d) => (d?.certStatus || '').toUpperCase() === 'ACTIVE') + .map((d) => ({ + kind: 'DSC', + name: 'DSC', + target: d.commonName || d.dscName || d.dscSerialNoMasked || `DSC #${d.id}`, + dsc: d + })); + const combined = [ + ...otpMethods.map((m) => ({ ...m, kind: 'OTP' })), + ...dscFactors + ]; + this.twoFactorAuthenticationDeliveryMethods = combined; + if (combined.length === 1) { + this.twoFactorAuthenticationDeliveryMethodForm + .get('twoFactorAuthenticationDeliveryMethod') + ?.setValue(combined[0]); + this.continueWithSelectedFactor(); + } + } + + /** Branches on the picked factor: OTP path vs DSC challenge path. */ + continueWithSelectedFactor(): void { + const method = this.twoFactorAuthenticationDeliveryMethodForm.value.twoFactorAuthenticationDeliveryMethod; + if (method?.kind === 'DSC') { + this.requestDscChallenge(method); + } else { + this.requestOTP(); + } + } + /** * Requests OTP via the selected delivery method. */ @@ -131,6 +196,149 @@ export class TwoFactorAuthenticationComponent implements OnInit { this.twoFactorAuthenticationDeliveryMethodForm.get('twoFactorAuthenticationDeliveryMethod')?.setValue(method); } + /** Icon name for a factor row in the picker / context strip. */ + iconFor(method: any): string { + const name = (method?.name || '').toLowerCase(); + if (method?.kind === 'DSC' || name === 'dsc') return 'signature'; + if (name === 'sms' || name === 'mobile' || name === 'phone') return 'mobile-alt'; + return 'envelope'; + } + + /** Start a DSC login challenge: POST /challenge → fire JNLP → begin polling. */ + requestDscChallenge(method: any): void { + this.loading = true; + this.dscFailed = false; + this.dscErrorMessage = ''; + this.dscChallengeActive = true; + this.selectedTwoFactorAuthenticationDeliveryMethod = method; + this.twoFactorAuthenticationDeliveryMethodForm.disable(); + + this.dscService + .requestLoginChallenge() + .pipe(finalize(() => (this.loading = false))) + .subscribe({ + next: (challenge) => { + this.dscChallenge = challenge; + this.dscSecondsRemaining = challenge.expiresInSeconds || 120; + this.dscDetecting = true; + this.tickDscCountdown(); + // Pass the whole challenge — the service builds the JNLP locally and bypasses + // JWS server fetches entirely. + this.dscService.downloadAndOpenJnlp(challenge); + this.beginDscPolling(); + }, + error: (err) => { + this.dscDetecting = false; + this.dscFailed = true; + this.dscErrorMessage = + err?.error?.defaultUserMessage || + err?.error?.message || + err?.message || + 'Could not start DSC verification.'; + } + }); + } + + /** Re-fire the JNLP without restarting the challenge. */ + reDownloadDscHelper(): void { + if (this.dscChallenge) this.dscService.downloadAndOpenJnlp(this.dscChallenge); + } + + /** Drop back to the factor picker without consuming the challenge. */ + useAnotherMethod(): void { + this.dscPollSub?.unsubscribe(); + this.dscChallengeActive = false; + this.dscChallenge = null; + this.dscDetecting = false; + this.dscFailed = false; + this.dscErrorMessage = ''; + this.selectedTwoFactorAuthenticationDeliveryMethod = null; + this.twoFactorAuthenticationDeliveryMethodForm.enable(); + this.twoFactorAuthenticationDeliveryMethodForm.reset(); + } + + private beginDscPolling(): void { + if (!this.dscChallenge?.randomNo) return; + const timeoutMs = (this.dscChallenge.expiresInSeconds || 120) * 1000; + this.dscPollSub?.unsubscribe(); + this.dscPollSub = this.dscService + .pollStatus(() => this.dscService.getLoginStatus(this.dscChallenge!.randomNo), timeoutMs) + .pipe(takeUntil(this.destroy$)) + .subscribe({ + next: (res) => { + if (res.status === 'VERIFIED') { + this.dscDetecting = false; + this.claimAndValidate(this.dscChallenge!.randomNo); + } else if (res.status === 'FAILED' || res.status === 'EXPIRED') { + this.dscDetecting = false; + this.dscFailed = true; + this.dscErrorMessage = + (res as any).errorMessage || + (res.status === 'EXPIRED' ? 'Verification took too long — try again.' : 'DSC verification failed.'); + } + }, + complete: () => { + if (this.dscDetecting) { + this.dscDetecting = false; + this.dscFailed = true; + this.dscErrorMessage = 'Verification took too long — try again.'; + } + }, + error: (err) => { + this.dscDetecting = false; + this.dscFailed = true; + this.dscErrorMessage = err?.error?.defaultUserMessage || err?.message || 'Polling failed.'; + } + }); + } + + /** After /status reports VERIFIED, fetch the TFA token via /claim and finalise login. */ + private claimAndValidate(randomNo: string): void { + this.loading = true; + this.dscService + .claimLoginToken(randomNo) + .pipe(finalize(() => (this.loading = false))) + .subscribe({ + next: (res: any) => { + const token: string | null = res?.tfaToken || null; + if (!token) { + this.dscFailed = true; + this.dscErrorMessage = + res?.errorMessage || 'DSC verified, but the verification token could not be claimed.'; + return; + } + // Reuse the existing /twofactor/validate pipeline; backend treats the + // DSC-issued bearer token the same as an OTP code. + this.authenticationService.validateOTP(token).subscribe({ + error: (err) => { + this.dscFailed = true; + this.dscErrorMessage = + err?.error?.defaultUserMessage || err?.error?.message || err?.message || 'Token rejected by server.'; + } + }); + }, + error: (err) => { + this.dscFailed = true; + this.dscErrorMessage = + err?.error?.defaultUserMessage || + err?.error?.message || + err?.message || + 'Could not claim verification token.'; + } + }); + } + + private tickDscCountdown(): void { + interval(1000) + .pipe(takeUntil(this.destroy$)) + .subscribe(() => { + if (this.dscSecondsRemaining > 0) this.dscSecondsRemaining--; + const m = Math.floor(this.dscSecondsRemaining / 60); + const s = this.dscSecondsRemaining % 60; + this.dscCountdownLabel = `${m}:${String(s).padStart(2, '0')}`; + }); + } + /** * Mask the delivery target so only the last 4 characters are visible. * Mobile → "+XXXXXX2076"; Email → "ad***@gmail.com". diff --git a/src/app/users/create-user/create-user.component.html b/src/app/users/create-user/create-user.component.html index 096865728..519399995 100644 --- a/src/app/users/create-user/create-user.component.html +++ b/src/app/users/create-user/create-user.component.html @@ -86,7 +86,7 @@ formControlName="email" placeholder="name@example.com" /> - @if (userForm.controls.email.hasError('email')) { + @if (userForm.controls.email.hasError('email') || userForm.controls.email.hasError('pattern')) { {{ 'labels.inputs.Email' | translate }} {{ 'labels.commons.is' | translate }} {{ 'labels.commons.invalid' | translate }} + +
+
+
+ + Digital Signatures +
+ +
+ + @if (loading) { + + } + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Common Name{{ row.commonName || '—' }}Serial + {{ row.dscSerialNoFull || row.dscSerialNoMasked || '—' }} + Issuer + {{ shortenDn(row.issuedBy) || '—' }} + Expires{{ formatDate(row.dscExpiryDate) }}Status + + + {{ statusLabel(row.certStatus) }} + + + + + + + +
+ + @if (!loading && errorStatus) { +
+ +
+ + @if (errorStatus === 403) { + Access denied (HTTP 403) + } @else if (errorStatus === 401) { + Sign in required (HTTP 401) + } @else { + Failed to load (HTTP {{ errorStatus }}) + } + + {{ errorMsg }} +
+
+ } @else if (!loading && dataSource.data.length === 0) { +
+ +

No DSC mapped — required only if you want DSC-based two-factor auth.

+
+ } +
+
diff --git a/src/app/users/dsc/dsc-list/dsc-list.component.scss b/src/app/users/dsc/dsc-list/dsc-list.component.scss new file mode 100644 index 000000000..652b21537 --- /dev/null +++ b/src/app/users/dsc/dsc-list/dsc-list.component.scss @@ -0,0 +1,163 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +.dsc-card { + background: #fff; + border: 1px solid #e2e8f0; + border-radius: 12px; + box-shadow: 0 1px 2px rgb(15 23 42 / 4%); + overflow: hidden; +} + +.dsc-card__head { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 20px; + border-bottom: 1px solid #e2e8f0; + background: linear-gradient(180deg, #f8fafc 0%, #fff 100%); +} + +.dsc-card__title { + display: flex; + align-items: center; + gap: 10px; + font-size: 1.05rem; + font-weight: 600; + color: #0f172a; +} + +.dsc-card__title-icon { + color: #3b82f6; +} + +.dsc-card__action { + display: inline-flex; + align-items: center; + gap: 8px; +} + +.dsc-card__body { + padding: 0; +} + +.dsc-table { + width: 100%; + + th.mat-mdc-header-cell { + background: #f8fafc; + color: #475569; + font-weight: 600; + font-size: 0.78rem; + text-transform: uppercase; + letter-spacing: 0.04em; + } + + td.mat-mdc-cell { + color: #0f172a; + font-size: 0.92rem; + } +} + +.dsc-mono { + font-family: 'SF Mono', Menlo, Consolas, monospace; + font-size: 0.85rem; + letter-spacing: 0.04em; +} + +.dsc-actions-col { + width: 48px; + text-align: right; +} + +.dsc-menu-icon { + margin-right: 10px; + color: #475569; + + &--danger { + color: #dc2626; + } +} + +.dsc-pill { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + border-radius: 999px; + font-size: 0.78rem; + font-weight: 600; + + &__dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: currentcolor; + } + + &.is-active { + background: #dcfce7; + color: #16a34a; + } + + &.is-expired { + background: #ffedd5; + color: #ea580c; + } + + &.is-revoked { + background: #fee2e2; + color: #dc2626; + } +} + +.dsc-state { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 24px 20px; + color: #475569; + + fa-icon { + font-size: 1.5rem; + margin-top: 2px; + } + + strong { + display: block; + color: #0f172a; + margin-bottom: 4px; + } + + span { + font-size: 0.88rem; + } + + p { + margin: 0; + font-size: 0.92rem; + } + + &--error { + background: #fef2f2; + color: #b91c1c; + } + + &--empty { + justify-content: center; + text-align: center; + flex-direction: column; + align-items: center; + padding: 36px 20px; + + fa-icon { + font-size: 2rem; + color: #cbd5e1; + } + } +} diff --git a/src/app/users/dsc/dsc-list/dsc-list.component.ts b/src/app/users/dsc/dsc-list/dsc-list.component.ts new file mode 100644 index 000000000..429c65c72 --- /dev/null +++ b/src/app/users/dsc/dsc-list/dsc-list.component.ts @@ -0,0 +1,191 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, Input, OnInit, inject } from '@angular/core'; +import { + MatTableDataSource, + MatTable, + MatColumnDef, + MatHeaderCellDef, + MatHeaderCell, + MatCellDef, + MatCell, + MatHeaderRowDef, + MatHeaderRow, + MatRowDef, + MatRow +} from '@angular/material/table'; +import { MatButton, MatIconButton } from '@angular/material/button'; +import { MatIcon } from '@angular/material/icon'; +import { MatMenu, MatMenuItem, MatMenuTrigger } from '@angular/material/menu'; +import { MatTooltip } from '@angular/material/tooltip'; +import { MatProgressBar } from '@angular/material/progress-bar'; +import { MatDialog } from '@angular/material/dialog'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; + +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { DscService } from '../dsc.service'; +import { BankUserDsc } from '../dsc.model'; +import { EnrollDscDialogComponent } from '../enroll-dsc-dialog/enroll-dsc-dialog.component'; +import { RevokeDscDialogComponent } from '../revoke-dsc-dialog/revoke-dsc-dialog.component'; + +@Component({ + selector: 'mifosx-dsc-list', + templateUrl: './dsc-list.component.html', + styleUrls: ['./dsc-list.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + MatTable, + MatColumnDef, + MatHeaderCellDef, + MatHeaderCell, + MatCellDef, + MatCell, + MatHeaderRowDef, + MatHeaderRow, + MatRowDef, + MatRow, + MatButton, + MatIconButton, + MatIcon, + MatMenu, + MatMenuItem, + MatMenuTrigger, + MatTooltip, + MatProgressBar, + FaIconComponent + ] +}) +export class DscListComponent implements OnInit { + private service = inject(DscService); + private dialog = inject(MatDialog); + + /** When set, the list is loaded for a specific user (admin view). Otherwise loads /me. */ + @Input() userId?: number; + + loading = false; + errorStatus = 0; + errorMsg = ''; + dataSource = new MatTableDataSource([]); + + readonly displayedColumns = [ + 'commonName', + 'serial', + 'issuer', + 'expiresOn', + 'status', + 'actions' + ]; + + ngOnInit(): void { + this.fetch(); + } + + fetch(): void { + this.loading = true; + this.errorStatus = 0; + this.errorMsg = ''; + const source$ = this.userId != null ? this.service.listForUser(this.userId) : this.service.listMyDscs(); + source$.subscribe({ + next: (rows) => { + this.loading = false; + this.dataSource.data = rows || []; + }, + error: (err) => { + this.loading = false; + this.dataSource.data = []; + this.errorStatus = err?.status || 0; + this.errorMsg = + err?.error?.defaultUserMessage || err?.error?.message || err?.message || 'Failed to load DSC list.'; + } + }); + } + + enroll(): void { + const ref = this.dialog.open(EnrollDscDialogComponent, { + width: '640px', + disableClose: true, + autoFocus: false, + panelClass: 'ed-dialog-panel' + }); + ref.afterClosed().subscribe((result) => { + if (result?.enrolled) this.fetch(); + }); + } + + revoke(row: BankUserDsc): void { + const ref = this.dialog.open(RevokeDscDialogComponent, { + width: '480px', + data: { dsc: row }, + autoFocus: false + }); + ref.afterClosed().subscribe((result) => { + if (result?.revoked) this.fetch(); + }); + } + + view(row: BankUserDsc): void { + if (row?.id == null) return; + this.service.getDsc(row.id).subscribe((full) => { + const merged = this.dataSource.data.map((d) => (d.id === full.id ? { ...d, ...full } : d)); + this.dataSource.data = merged; + }); + } + + statusClass(status?: string): string { + if (!status) return ''; + const s = status.toUpperCase(); + if (s === 'ACTIVE') return 'is-active'; + if (s === 'EXPIRED') return 'is-expired'; + if (s === 'REVOKED') return 'is-revoked'; + return ''; + } + + statusLabel(status?: string): string { + if (!status) return '—'; + const s = status.toUpperCase(); + if (s === 'ACTIVE') return 'Active'; + if (s === 'EXPIRED') return 'Expired'; + if (s === 'REVOKED') return 'Revoked'; + return s; + } + + formatDate(value: any): string { + if (!value) return '—'; + if (Array.isArray(value) && value.length >= 3) { + const [ + y, + m, + d + ] = value; + return new Date(y, m - 1, d).toLocaleDateString(); + } + if (typeof value === 'string') { + const dt = new Date(value); + return isNaN(dt.getTime()) ? value : dt.toLocaleDateString(); + } + return String(value); + } + + /** + * Backend stores cert issuer as a full LDAP DN ("CN=eMudhra Sub CA for Class 3 - 2019,O=eMudhra + * Limited,C=IN") which is too long for the table cell. Surface just the CN; the full DN is shown + * on hover via matTooltip. + */ + shortenDn(dn?: string | null): string { + if (!dn) return ''; + for (const part of dn.split(',')) { + const [ + k, + v + ] = part.trim().split('=', 2); + if (k && k.trim().toUpperCase() === 'CN' && v) return v.trim(); + } + return dn; + } +} diff --git a/src/app/users/dsc/dsc.model.ts b/src/app/users/dsc/dsc.model.ts new file mode 100644 index 000000000..7813779eb --- /dev/null +++ b/src/app/users/dsc/dsc.model.ts @@ -0,0 +1,84 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +export type DscCertStatus = 'ACTIVE' | 'EXPIRED' | 'REVOKED'; +export type DscChallengeStatusValue = 'PENDING' | 'VERIFIED' | 'FAILED' | 'EXPIRED'; + +/** + * One row in m_bank_user_dsc as returned by GET /v1/dsc/me, GET /v1/dsc/user/{userId} and + * GET /v1/dsc/{id}. Field names match the backend's BankUserDscData DTO verbatim. Only the + * full serial is null on list endpoints; everything else is populated. + */ +export interface BankUserDsc { + id: number; + appUserId: number; + username: string; + dscName: string; + /** Last six chars visible; the rest masked with bullets. Always present. */ + dscSerialNoMasked: string; + /** Full serial — null on list endpoints, populated by GET /v1/dsc/{id}. */ + dscSerialNoFull: string | null; + dscType: string; + commonName: string; + issuedTo: string; + issuedBy: string; + dscExpiryDate: string; + dateOfSubmission: string; + certStatus: DscCertStatus; + revokedOn: string | null; + revokeReason: string | null; +} + +/** Response from POST /v1/dsc/enroll/start. */ +export interface DscEnrollSession { + randomNo: string; + jnlpUrl: string; + statusUrl: string; + expiresInSeconds: number; + /** + * Pre-stringified JSON the helper expects in its launch argument. The UI embeds this verbatim + * into a locally-built JNLP file's element — JWS never has to fetch a JNLP from + * the server. + */ + helperArgsJson: string; + /** Relative URL the UI XHRs to fetch the helper JAR bytes (Authed via the HTTP interceptor). */ + helperJarUrl: string; +} + +/** Response from GET /v1/dsc/enroll/{rn}/status. */ +export interface DscChallengeStatus { + randomNo: string; + status: DscChallengeStatusValue; + errorMessage: string | null; + /** Set only when status === 'VERIFIED'. */ + dsc: BankUserDsc | null; +} + +/** Response from POST /v1/login/dsc/challenge. */ +export interface DscLoginChallenge { + randomNo: string; + jnlpUrl: string; + statusUrl: string; + expiresInSeconds: number; + /** Same shape as {@link DscEnrollSession.helperArgsJson} but for SIGN_CHALLENGE mode. */ + helperArgsJson: string; + /** Same as {@link DscEnrollSession.helperJarUrl}. */ + helperJarUrl: string; +} + +/** + * Response from GET /v1/login/dsc/{rn}/status and POST /v1/login/dsc/{rn}/claim. The /status + * endpoint never sets tfaToken; the /claim endpoint sets it exactly once (single-use). + */ +export interface DscLoginStatus { + randomNo: string; + status: DscChallengeStatusValue; + errorMessage: string | null; + tfaToken: string | null; + tfaTokenLiveSeconds: number | null; +} diff --git a/src/app/users/dsc/dsc.service.ts b/src/app/users/dsc/dsc.service.ts new file mode 100644 index 000000000..4ce73ad76 --- /dev/null +++ b/src/app/users/dsc/dsc.service.ts @@ -0,0 +1,173 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Injectable, inject } from '@angular/core'; +import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http'; +import { Observable, interval, timer, NEVER } from 'rxjs'; +import { switchMap, takeWhile, takeUntil, startWith } from 'rxjs/operators'; + +import { BankUserDsc, DscEnrollSession, DscChallengeStatus, DscLoginChallenge, DscLoginStatus } from './dsc.model'; + +@Injectable({ providedIn: 'root' }) +export class DscService { + private http = inject(HttpClient); + + /** Suppress browser native basic-auth dialog on 401 from these endpoints. */ + private silentHeaders = new HttpHeaders({ 'X-Requested-With': 'XMLHttpRequest' }); + + // ── Enrollment ──────────────────────────────────────────────── + startEnrollment(): Observable { + return this.http.post('/v1/dsc/enroll/start', {}, { headers: this.silentHeaders }); + } + + getEnrollStatus(randomNo: string): Observable { + return this.http.get(`/v1/dsc/enroll/${encodeURIComponent(randomNo)}/status`); + } + + // ── List / detail / revoke ──────────────────────────────────── + listMyDscs(): Observable { + return this.http.get('/v1/dsc/me', { headers: this.silentHeaders }); + } + + listForUser(userId: number): Observable { + return this.http.get(`/v1/dsc/user/${userId}`, { headers: this.silentHeaders }); + } + + getDsc(id: number): Observable { + return this.http.get(`/v1/dsc/${id}`, { headers: this.silentHeaders }); + } + + revokeDsc(id: number, reason: string): Observable { + const params = new HttpParams().set('reason', reason); + return this.http.delete(`/v1/dsc/${id}`, { params, headers: this.silentHeaders }); + } + + // ── Login factor ────────────────────────────────────────────── + requestLoginChallenge(): Observable { + return this.http.post('/v1/login/dsc/challenge', {}, { headers: this.silentHeaders }); + } + + getLoginStatus(randomNo: string): Observable { + return this.http.get(`/v1/login/dsc/${encodeURIComponent(randomNo)}/status`); + } + + /** Single-use claim — returns the 2FA bearer token once polling has seen VERIFIED. */ + claimLoginToken(randomNo: string): Observable { + return this.http.post( + `/v1/login/dsc/${encodeURIComponent(randomNo)}/claim`, + {}, + { headers: this.silentHeaders } + ); + } + + // ── Helper ──────────────────────────────────────────────────── + /** + * Download both the helper JAR and a locally-generated JNLP file into the user's Downloads + * folder. The JNLP references the JAR by RELATIVE path, so when the user double-clicks the + * JNLP, Java Web Start resolves '' against the JNLP's + * own location on disk — JWS makes ZERO HTTP requests to Fineract. This sidesteps every + * variant of the "Authentication Required" / tenant-filter rejection problem we hit when JWS + * tries to fetch resources from the server without our auth interceptor. + * + * @param session start-response from /enroll/start or /login/dsc/challenge — must carry + * {@code helperJarUrl} and {@code helperArgsJson}. + */ + downloadAndOpenJnlp(session: { helperJarUrl?: string; helperArgsJson?: string } | string): void { + // Backwards-compat: if called with a string URL (old API), log and abort — the new flow + // requires the full session object so we can build the JNLP locally. + if (typeof session === 'string' || !session?.helperJarUrl || !session?.helperArgsJson) { + console.error('[DSC] downloadAndOpenJnlp expects {helperJarUrl, helperArgsJson} now; got:', session); + return; + } + + // 1. Fetch helper JAR bytes via the authed HTTP client (interceptor adds tenant + auth headers). + // `silentHeaders` (X-Requested-With: XMLHttpRequest) is critical — without it, any 401 + // response would pop up the browser's native Basic-auth dialog. With it, Fineract's auth + // entry point returns 401 silently and our interceptor handles it. + this.http.get(session.helperJarUrl, { responseType: 'blob', headers: this.silentHeaders }).subscribe({ + next: (jarBlob) => { + // 2. Save the JAR to Downloads. + this.saveBlob(jarBlob, 'cbsnexora-dsc-helper.jar'); + + // 3. Build a tiny JNLP locally that references the JAR by relative path. + const jnlpXml = this.buildLocalJnlp(session.helperArgsJson!); + const jnlpBlob = new Blob([jnlpXml], { type: 'application/x-java-jnlp-file' }); + + // 4. Save the JNLP after a tiny delay so the first download has registered with the + // browser (avoids some browsers silently dropping rapid back-to-back triggers). + setTimeout(() => this.saveBlob(jnlpBlob, 'cbsnexora-dsc.jnlp'), 200); + }, + error: (err) => { + console.error('[DSC] Failed to fetch helper JAR:', err); + } + }); + } + + private saveBlob(blob: Blob, filename: string): void { + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + setTimeout(() => URL.revokeObjectURL(url), 300); + } + + /** + * Generates the JNLP XML body. No {@code codebase} attribute — relative URLs resolve against + * the JNLP file's location on disk, so JWS reads the JAR from the same Downloads folder. + */ + private buildLocalJnlp(argsJson: string): string { + const escaped = argsJson + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + return ` + + + CBSnexora + CBSnexora DSC Helper + Reads your DSC for CBSnexora two-factor authentication. + + + + + + + + + + ${escaped} + + +`; + } + + /** + * Polls a status endpoint with backoff (2s for first 30s, then 5s) and a + * hard timeout. Emits each status as it arrives; completes on a terminal + * state or when the timeout fires. + */ + pollStatus(fetcher: () => Observable, timeoutMs: number): Observable { + const fastWindowMs = 30_000; + return interval(2000).pipe( + startWith(0), + switchMap((tick) => { + const elapsed = tick * 2000; + if (elapsed <= fastWindowMs) return fetcher(); + // Slow window: only fire every other tick (every 4s after 30s — close to 5s). + return tick % 2 === 0 ? fetcher() : NEVER; + }), + takeWhile((r) => r.status === 'PENDING', true), + takeUntil(timer(timeoutMs)) + ); + } +} diff --git a/src/app/users/dsc/enroll-dsc-dialog/enroll-dsc-dialog.component.html b/src/app/users/dsc/enroll-dsc-dialog/enroll-dsc-dialog.component.html new file mode 100644 index 000000000..04cb94d69 --- /dev/null +++ b/src/app/users/dsc/enroll-dsc-dialog/enroll-dsc-dialog.component.html @@ -0,0 +1,229 @@ + + + +
+
+ +
+
+

Map a Digital Signature

+

Register your DSC token as a secure way to sign in.

+
+
+ + +
+
+ + @if (stepIndex > 0) { + + } @else { + 1 + } + + Prepare +
+ +
+ + @if (stepIndex > 1) { + + } @else { + 2 + } + + Detect +
+ +
+ + @if (stepIndex > 2) { + + } @else { + 3 + } + + Confirm +
+ +
+ + @if (stepIndex === 3) { + + } @else { + 4 + } + + Done +
+
+ + + + + Prepare + Detect + Confirm + Done + + + + @if (stepIndex === 0) { +

A few quick checks before we look for your token.

+ +
+
+
+ +
+
+ DSC token plugged in + Insert your USB token into any free port on this machine. +
+
+ +
+
+ +
+
+ Driver installed + The token's driver (eMudhra / Sify / Capricorn etc.) is installed. +
+
+ +
+
+ +
+
+ OpenWebStart available + Required to run the helper — Oracle Java Web Start was removed in JDK 11. +
+
+
+ + + + + Need OpenWebStart? + Get the free installer for Windows, macOS, or Linux. + + + + } + + + @if (stepIndex === 1) { + @if (detecting) { +
+
+ + +
+

Waiting for DSC detection…

+

+ A helper file was downloaded. If your browser didn't open it automatically, click + Re-download helper. +

+ @if (countdownLabel) { + + + {{ countdownLabel }} remaining + + } + +
+ } + + @if (failed) { +
+
+ +
+

Couldn't verify your DSC

+

{{ errorMessage }}

+ +
+ } + } + + + @if (stepIndex === 2) { +

+ + We detected the following certificate. It's been mapped to your account. +

+ + @if (confirmedDsc) { +
+
+ Common Name + {{ confirmedDsc.commonName || '—' }} +
+
+ Issued To + {{ confirmedDsc.issuedTo || '—' }} +
+
+ Issued By + {{ confirmedDsc.issuedBy || '—' }} +
+
+ Serial No + + {{ confirmedDsc.dscSerialNoFull || confirmedDsc.dscSerialNoMasked || '—' }} + +
+
+ Expires + {{ formatDate(confirmedDsc.dscExpiryDate) }} +
+
+ } + } + + + @if (stepIndex === 3) { +
+
+ +
+

DSC mapped successfully

+

You can now select this certificate as a second factor at sign-in.

+
+ } +
+ + + @switch (stepIndex) { + @case (0) { + + + } + @case (1) { + + } + @case (2) { + + } + @default { + + } + } + diff --git a/src/app/users/dsc/enroll-dsc-dialog/enroll-dsc-dialog.component.scss b/src/app/users/dsc/enroll-dsc-dialog/enroll-dsc-dialog.component.scss new file mode 100644 index 000000000..b81c477c1 --- /dev/null +++ b/src/app/users/dsc/enroll-dsc-dialog/enroll-dsc-dialog.component.scss @@ -0,0 +1,467 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +:host { + display: block; + min-width: 560px; + max-width: 640px; + font-family: inherit; + color: #0f172a; +} + +/* ───────────────────────── Hero header ───────────────────────── */ +.ed-hero { + display: flex; + align-items: center; + gap: 14px; + padding: 22px 24px 18px; + background: linear-gradient(135deg, #eff6ff 0%, #fff 60%); + border-bottom: 1px solid #dbeafe; + + &__icon { + width: 48px; + height: 48px; + border-radius: 12px; + background: #1e40af; + color: #fff; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 20px; + flex-shrink: 0; + } + + &__text { + flex: 1; + min-width: 0; + } + + &__title { + margin: 0 0 4px; + font-size: 18px; + font-weight: 700; + color: #0f172a; + } + + &__sub { + margin: 0; + font-size: 13px; + color: #475569; + line-height: 1.5; + } +} + +/* ───────────────────────── Step indicator ───────────────────────── */ +.ed-steps { + display: flex; + align-items: center; + gap: 6px; + padding: 18px 24px; + background: #fff; + border-bottom: 1px solid #e2e8f0; +} + +.ed-step { + display: flex; + align-items: center; + gap: 8px; + color: #94a3b8; + font-size: 13px; + font-weight: 600; + transition: color 0.2s ease; + + &__circle { + width: 28px; + height: 28px; + border-radius: 50%; + background: #e2e8f0; + color: #64748b; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 12px; + font-weight: 700; + flex-shrink: 0; + transition: + background 0.2s ease, + color 0.2s ease; + } + + &__bar { + flex: 1; + height: 2px; + background: #e2e8f0; + border-radius: 1px; + transition: background 0.2s ease; + + &.is-active { + background: #1e40af; + } + } + + &__label { + white-space: nowrap; + } + + &.is-active { + color: #1e40af; + + .ed-step__circle { + background: #1e40af; + color: #fff; + box-shadow: 0 0 0 4px rgb(30 64 175 / 12%); + } + } + + &.is-done { + color: #0f172a; + + .ed-step__circle { + background: #16a34a; + color: #fff; + } + } +} + +/* ───────────────────────── Content area ───────────────────────── */ +.ed-content { + padding: 22px 24px !important; + background: #f8fafc; + max-height: 60vh; + overflow-y: auto; +} + +/* Hide the Material stepper's default header — we render our own indicator. */ +.ed-stepper-hidden { + display: none !important; +} + +.ed-intro { + display: flex; + align-items: center; + gap: 8px; + margin: 0 0 16px; + color: #334155; + font-size: 14px; + line-height: 1.5; + + &__icon { + font-size: 18px; + + &--ok { + color: #16a34a; + } + } +} + +/* ───────────────────────── Prepare — requirement cards ───────────────────────── */ +.ed-checks { + display: flex; + flex-direction: column; + gap: 10px; + margin-bottom: 18px; +} + +.ed-check { + display: flex; + align-items: center; + gap: 14px; + padding: 14px 16px; + background: #fff; + border: 1px solid #e2e8f0; + border-radius: 12px; + transition: + border-color 0.18s ease, + box-shadow 0.18s ease; + + &:hover { + border-color: #c7d2fe; + box-shadow: 0 4px 14px rgb(15 23 42 / 4%); + } + + &__icon { + width: 40px; + height: 40px; + border-radius: 10px; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 16px; + flex-shrink: 0; + + &--blue { + background: #dbeafe; + color: #1e40af; + } + + &--purple { + background: #ede9fe; + color: #6d28d9; + } + + &--green { + background: #dcfce7; + color: #15803d; + } + } + + &__body { + display: flex; + flex-direction: column; + gap: 2px; + + strong { + color: #0f172a; + font-size: 14px; + font-weight: 600; + } + + span { + color: #64748b; + font-size: 12.5px; + line-height: 1.5; + } + } +} + +/* ───────────────────────── OpenWebStart callout ───────────────────────── */ +.ed-callout { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 16px; + background: linear-gradient(135deg, #fef9c3 0%, #fff 80%); + border: 1px solid #fde68a; + border-radius: 12px; + text-decoration: none; + color: #78350f; + transition: transform 0.15s ease; + + &:hover { + transform: translateY(-1px); + } + + &__icon { + font-size: 18px; + color: #b45309; + flex-shrink: 0; + } + + &__text { + flex: 1; + font-size: 13px; + line-height: 1.4; + + strong { + display: block; + color: #92400e; + font-weight: 600; + margin-bottom: 1px; + } + } + + &__chev { + color: #b45309; + flex-shrink: 0; + } +} + +/* ───────────────────────── Detect — spinner ───────────────────────── */ +.ed-detect { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + gap: 12px; + padding: 8px 8px 16px; + + &__ring { + position: relative; + width: 64px; + height: 64px; + display: inline-flex; + align-items: center; + justify-content: center; + } + + &__inner { + position: absolute; + color: #1e40af; + font-size: 22px; + } + + &__title { + margin: 4px 0 0; + color: #0f172a; + font-size: 16px; + font-weight: 600; + } + + &__sub { + margin: 0; + max-width: 440px; + color: #475569; + font-size: 13px; + line-height: 1.5; + } +} + +.ed-pill { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + border-radius: 999px; + background: #dbeafe; + color: #1e40af; + font-size: 12px; + font-weight: 600; + + fa-icon { + font-size: 11px; + } +} + +.ed-redownload { + margin-top: 4px; + display: inline-flex !important; + align-items: center; + gap: 8px; +} + +/* ───────────────────────── Detect — error state ───────────────────────── */ +.ed-error { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + gap: 10px; + padding: 16px; + background: #fff; + border: 1px solid #fecaca; + border-radius: 12px; + + &__icon { + width: 56px; + height: 56px; + border-radius: 50%; + background: #fee2e2; + color: #b91c1c; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 26px; + } + + h3 { + margin: 0; + color: #0f172a; + font-size: 16px; + } + + p { + margin: 0; + color: #475569; + font-size: 13px; + max-width: 440px; + line-height: 1.5; + } +} + +/* ───────────────────────── Confirm — cert details ───────────────────────── */ +.ed-cert { + background: #fff; + border: 1px solid #e2e8f0; + border-radius: 12px; + overflow: hidden; + + &__row { + display: grid; + grid-template-columns: 140px 1fr; + gap: 16px; + padding: 12px 16px; + border-bottom: 1px solid #f1f5f9; + + &:last-child { + border-bottom: none; + } + } + + &__label { + color: #64748b; + font-size: 12.5px; + font-weight: 500; + } + + &__value { + color: #0f172a; + font-size: 13px; + word-break: break-all; + } + + &__mono { + font-family: 'SF Mono', Menlo, Consolas, monospace; + font-size: 12.5px; + letter-spacing: 0.02em; + } +} + +/* ───────────────────────── Done ───────────────────────── */ +.ed-done { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + gap: 12px; + padding: 24px 16px; + + &__icon { + width: 72px; + height: 72px; + border-radius: 50%; + background: #dcfce7; + color: #16a34a; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 36px; + } + + h3 { + margin: 4px 0 0; + color: #0f172a; + font-size: 18px; + font-weight: 700; + } + + p { + margin: 0; + color: #475569; + font-size: 13px; + max-width: 380px; + line-height: 1.5; + } +} + +/* ───────────────────────── Actions ───────────────────────── */ +.ed-actions { + padding: 14px 20px !important; + background: #fff; + border-top: 1px solid #e2e8f0; + gap: 10px; + + button { + display: inline-flex !important; + align-items: center; + gap: 8px; + } + + .ed-btn-icon { + font-size: 11px; + } +} diff --git a/src/app/users/dsc/enroll-dsc-dialog/enroll-dsc-dialog.component.ts b/src/app/users/dsc/enroll-dsc-dialog/enroll-dsc-dialog.component.ts new file mode 100644 index 000000000..eb1e3cb3d --- /dev/null +++ b/src/app/users/dsc/enroll-dsc-dialog/enroll-dsc-dialog.component.ts @@ -0,0 +1,191 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, OnDestroy, ViewChild, inject } from '@angular/core'; +import { MatDialogRef, MatDialogTitle, MatDialogContent, MatDialogActions } from '@angular/material/dialog'; +import { MatStepper, MatStep, MatStepLabel } from '@angular/material/stepper'; +import { MatButton } from '@angular/material/button'; +import { MatProgressSpinner } from '@angular/material/progress-spinner'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { Subscription, Subject, interval, takeUntil } from 'rxjs'; + +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { DscService } from '../dsc.service'; +import { BankUserDsc, DscEnrollSession } from '../dsc.model'; + +@Component({ + selector: 'mifosx-enroll-dsc-dialog', + templateUrl: './enroll-dsc-dialog.component.html', + styleUrls: ['./enroll-dsc-dialog.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + MatDialogTitle, + MatDialogContent, + MatDialogActions, + MatStepper, + MatStep, + MatStepLabel, + MatButton, + MatProgressSpinner, + FaIconComponent + ] +}) +export class EnrollDscDialogComponent implements OnDestroy { + private service = inject(DscService); + private dialogRef = inject(MatDialogRef); + + @ViewChild('stepper') stepper!: MatStepper; + + /** Stepper index (0..3). */ + stepIndex = 0; + + /** Started enrollment session details. */ + session: DscEnrollSession | null = null; + + /** Confirmed DSC details (populated on VERIFIED). */ + confirmedDsc: BankUserDsc | null = null; + + /** Status flags for the Detect step. */ + detecting = false; + failed = false; + errorMessage = ''; + + /** Seconds remaining for the challenge — driven off server response. */ + secondsRemaining = 0; + countdownLabel = ''; + + private pollSub?: Subscription; + private destroy$ = new Subject(); + + next(): void { + this.stepper?.next(); + this.stepIndex = this.stepper?.selectedIndex ?? this.stepIndex; + } + + cancel(): void { + this.cleanup(); + this.dialogRef.close({ enrolled: false }); + } + + done(): void { + this.cleanup(); + this.dialogRef.close({ enrolled: true, dsc: this.confirmedDsc }); + } + + /** Start the enrollment: POST /start, fire JNLP, begin polling. */ + startDetection(): void { + this.detecting = true; + this.failed = false; + this.errorMessage = ''; + this.confirmedDsc = null; + this.next(); + + this.service.startEnrollment().subscribe({ + next: (session) => { + this.session = session; + this.secondsRemaining = session.expiresInSeconds || 300; + this.tickCountdown(); + // Pass the whole session: the service uses helperJarUrl + helperArgsJson to build the JNLP + // locally and bypass JWS server fetches entirely. + this.service.downloadAndOpenJnlp(session); + this.beginPolling(); + }, + error: (err) => { + this.detecting = false; + this.failed = true; + this.errorMessage = + err?.error?.defaultUserMessage || err?.error?.message || err?.message || 'Could not start enrollment.'; + } + }); + } + + reDownload(): void { + if (this.session) this.service.downloadAndOpenJnlp(this.session); + } + + private beginPolling(): void { + if (!this.session?.randomNo) return; + const timeoutMs = (this.session.expiresInSeconds || 300) * 1000; + this.pollSub?.unsubscribe(); + this.pollSub = this.service + .pollStatus(() => this.service.getEnrollStatus(this.session!.randomNo), timeoutMs) + .pipe(takeUntil(this.destroy$)) + .subscribe({ + next: (res) => { + if (res.status === 'VERIFIED') { + this.detecting = false; + this.confirmedDsc = res.dsc || null; + this.next(); + } else if (res.status === 'FAILED' || res.status === 'EXPIRED') { + this.detecting = false; + this.failed = true; + this.errorMessage = + res.errorMessage || + (res.status === 'EXPIRED' ? 'Verification took too long — try again.' : 'DSC verification failed.'); + } + }, + complete: () => { + if (this.detecting) { + this.detecting = false; + this.failed = true; + this.errorMessage = 'Verification took too long — try again.'; + } + }, + error: (err) => { + this.detecting = false; + this.failed = true; + this.errorMessage = err?.error?.defaultUserMessage || err?.message || 'Polling failed.'; + } + }); + } + + private tickCountdown(): void { + interval(1000) + .pipe(takeUntil(this.destroy$)) + .subscribe(() => { + if (this.secondsRemaining > 0) this.secondsRemaining--; + const m = Math.floor(this.secondsRemaining / 60); + const s = this.secondsRemaining % 60; + this.countdownLabel = `${m}:${String(s).padStart(2, '0')}`; + }); + } + + retry(): void { + this.failed = false; + this.errorMessage = ''; + this.stepper?.previous(); + this.stepIndex = this.stepper?.selectedIndex ?? this.stepIndex; + } + + formatDate(value: any): string { + if (!value) return '—'; + if (Array.isArray(value) && value.length >= 3) { + const [ + y, + m, + d + ] = value; + return new Date(y, m - 1, d).toLocaleDateString(); + } + if (typeof value === 'string') { + const dt = new Date(value); + return isNaN(dt.getTime()) ? value : dt.toLocaleDateString(); + } + return String(value); + } + + ngOnDestroy(): void { + this.cleanup(); + } + + private cleanup(): void { + this.destroy$.next(); + this.destroy$.complete(); + this.pollSub?.unsubscribe(); + } +} diff --git a/src/app/users/dsc/revoke-dsc-dialog/revoke-dsc-dialog.component.html b/src/app/users/dsc/revoke-dsc-dialog/revoke-dsc-dialog.component.html new file mode 100644 index 000000000..ccef53510 --- /dev/null +++ b/src/app/users/dsc/revoke-dsc-dialog/revoke-dsc-dialog.component.html @@ -0,0 +1,57 @@ + + +

+ + Revoke Digital Signature +

+ + +

+ You're about to revoke + {{ data.dsc.commonName || 'this DSC' }} + (serial {{ data.dsc.dscSerialNoFull || data.dsc.dscSerialNoMasked || '—' }}). This is permanent and the certificate + will no longer work for two-factor authentication. +

+ + + Reason + + @if (!reason?.trim() && submitting === false) { + Reason is required + } + + + @if (errorMessage) { +
+ + {{ errorMessage }} +
+ } +
+ + + + + diff --git a/src/app/users/dsc/revoke-dsc-dialog/revoke-dsc-dialog.component.scss b/src/app/users/dsc/revoke-dsc-dialog/revoke-dsc-dialog.component.scss new file mode 100644 index 000000000..4fca8ea0b --- /dev/null +++ b/src/app/users/dsc/revoke-dsc-dialog/revoke-dsc-dialog.component.scss @@ -0,0 +1,60 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +.revoke-title { + display: flex; + align-items: center; + gap: 10px; + color: #b91c1c; + margin: 0; + + &__icon { + color: #dc2626; + } +} + +.revoke-content { + padding-top: 8px; +} + +.revoke-summary { + margin: 0 0 16px; + color: #334155; + font-size: 0.95rem; + line-height: 1.5; + + strong { + color: #0f172a; + } +} + +.revoke-reason { + width: 100%; +} + +.revoke-error { + display: flex; + align-items: center; + gap: 8px; + margin-top: 8px; + padding: 10px 12px; + border-radius: 6px; + background: #fef2f2; + color: #b91c1c; + font-size: 0.88rem; +} + +.revoke-actions { + gap: 8px; + + button { + display: inline-flex; + align-items: center; + gap: 6px; + } +} diff --git a/src/app/users/dsc/revoke-dsc-dialog/revoke-dsc-dialog.component.ts b/src/app/users/dsc/revoke-dsc-dialog/revoke-dsc-dialog.component.ts new file mode 100644 index 000000000..efdf51e46 --- /dev/null +++ b/src/app/users/dsc/revoke-dsc-dialog/revoke-dsc-dialog.component.ts @@ -0,0 +1,80 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, inject } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { + MAT_DIALOG_DATA, + MatDialogRef, + MatDialogTitle, + MatDialogContent, + MatDialogActions +} from '@angular/material/dialog'; +import { MatButton } from '@angular/material/button'; +import { MatFormField, MatLabel, MatError } from '@angular/material/form-field'; +import { MatInput } from '@angular/material/input'; +import { MatProgressSpinner } from '@angular/material/progress-spinner'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; + +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { DscService } from '../dsc.service'; +import { BankUserDsc } from '../dsc.model'; + +interface RevokeDialogData { + dsc: BankUserDsc; +} + +@Component({ + selector: 'mifosx-revoke-dsc-dialog', + templateUrl: './revoke-dsc-dialog.component.html', + styleUrls: ['./revoke-dsc-dialog.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + FormsModule, + MatDialogTitle, + MatDialogContent, + MatDialogActions, + MatButton, + MatFormField, + MatLabel, + MatError, + MatInput, + MatProgressSpinner, + FaIconComponent + ] +}) +export class RevokeDscDialogComponent { + private service = inject(DscService); + private dialogRef = inject(MatDialogRef); + data = inject(MAT_DIALOG_DATA); + + reason = ''; + submitting = false; + errorMessage = ''; + + cancel(): void { + this.dialogRef.close({ revoked: false }); + } + + confirm(): void { + if (!this.reason?.trim() || !this.data?.dsc?.id) return; + this.submitting = true; + this.errorMessage = ''; + this.service.revokeDsc(this.data.dsc.id, this.reason.trim()).subscribe({ + next: () => { + this.submitting = false; + this.dialogRef.close({ revoked: true }); + }, + error: (err) => { + this.submitting = false; + this.errorMessage = + err?.error?.defaultUserMessage || err?.error?.message || err?.message || 'Could not revoke DSC.'; + } + }); + } +} diff --git a/src/app/users/edit-user/edit-user.component.html b/src/app/users/edit-user/edit-user.component.html index c84caee31..537432f75 100644 --- a/src/app/users/edit-user/edit-user.component.html +++ b/src/app/users/edit-user/edit-user.component.html @@ -81,7 +81,7 @@ {{ 'labels.inputs.Email' | translate }} email - @if (editUserForm.controls.email.hasError('email')) { + @if (editUserForm.controls.email.hasError('email') || editUserForm.controls.email.hasError('pattern')) { {{ 'labels.inputs.Email' | translate }} {{ 'labels.commons.is' | translate }} {{ 'labels.commons.invalid' | translate }} { - this.router.navigate( - [ - '../../', - response.resourceId - ], - { relativeTo: this.route } - ); + this.usersService.editUser(this.userData.id, editedUser).subscribe({ + next: (response: any) => { + // Fineract sometimes omits resourceId in PUT responses — fall back to + // the user id we're editing so navigation never silently no-ops. + const targetId = response?.resourceId ?? this.userData.id; + this.snackBar.open('User updated successfully', 'OK', { + duration: 3000, + panelClass: ['success-snackbar'] + }); + this.router.navigate( + [ + '../../', + targetId + ], + { relativeTo: this.route } + ); + }, + error: (err) => { + console.error('[EditUser] update failed', err); + this.snackBar.open('Failed to update user', 'Close', { + duration: 4000, + panelClass: ['error-snackbar'] + }); + } }); } } diff --git a/src/app/users/users.module.ts b/src/app/users/users.module.ts index 7e1f27107..d04a65ccc 100644 --- a/src/app/users/users.module.ts +++ b/src/app/users/users.module.ts @@ -19,6 +19,11 @@ import { CreateUserComponent } from './create-user/create-user.component'; import { ViewUserComponent } from './view-user/view-user.component'; import { EditUserComponent } from './edit-user/edit-user.component'; +/** DSC Components */ +import { DscListComponent } from './dsc/dsc-list/dsc-list.component'; +import { EnrollDscDialogComponent } from './dsc/enroll-dsc-dialog/enroll-dsc-dialog.component'; +import { RevokeDscDialogComponent } from './dsc/revoke-dsc-dialog/revoke-dsc-dialog.component'; + /** Custom Components of Zitadel */ import { ViewUserComponent as ViewUserZitadelComponent } from '../zitadel/users/view-user/view-user.component'; import { EditUserComponent as EditUserZitadelComponent } from '../zitadel/users/edit-user/edit-user.component'; @@ -38,6 +43,9 @@ import { UsersComponent as UserZitadelComponent } from '../zitadel/users/users.c CreateUserComponent, ViewUserComponent, EditUserComponent, + DscListComponent, + EnrollDscDialogComponent, + RevokeDscDialogComponent, // Users Zitadel UserZitadelComponent, ViewUserZitadelComponent, diff --git a/src/app/users/view-user/view-user.component.ts b/src/app/users/view-user/view-user.component.ts index 5bb8d4ac5..0b224ee6e 100644 --- a/src/app/users/view-user/view-user.component.ts +++ b/src/app/users/view-user/view-user.component.ts @@ -12,7 +12,6 @@ import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { MatDialog } from '@angular/material/dialog'; import { MatMenuModule } from '@angular/material/menu'; import { MatIconButton } from '@angular/material/button'; -import { MatTooltip } from '@angular/material/tooltip'; /** Custom Services */ import { UsersService } from '../users.service'; @@ -35,8 +34,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; ...STANDALONE_SHARED_IMPORTS, FaIconComponent, MatMenuModule, - MatIconButton, - MatTooltip + MatIconButton ] }) export class ViewUserComponent { diff --git a/src/environments/.env.ts b/src/environments/.env.ts index 77fbcad14..ef9ffed0d 100644 --- a/src/environments/.env.ts +++ b/src/environments/.env.ts @@ -5,12 +5,13 @@ * 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/. */ + // IMPORTANT: THIS FILE IS AUTO GENERATED! DO NOT MANUALLY EDIT OR CHECKIN! /* tslint:disable */ export default { 'mifos_x': { - 'version': '260509', - 'hash': 'g2d4fb4237' + 'version': '260511', + 'hash': 'g3b752dc6a' }, 'allow_switching_backend_instance': true }; diff --git a/src/main.scss b/src/main.scss index a490be64e..47df0fe3d 100644 --- a/src/main.scss +++ b/src/main.scss @@ -1067,3 +1067,18 @@ mat-card { background: rgb(2 6 23 / 75%) !important; backdrop-filter: blur(4px); } + +/* ─── DSC Enroll Dialog Panel — edge-to-edge hero ─── */ +.ed-dialog-panel { + .mat-mdc-dialog-container { + padding: 0; + border-radius: 16px; + overflow: hidden; + } + + .mat-mdc-dialog-surface { + padding: 0 !important; + border-radius: 16px !important; + overflow: hidden !important; + } +}