merege code this branch /feature/UI-DSC-MFA
This commit is contained in:
@@ -203,7 +203,7 @@
|
||||
} @else {
|
||||
<span class="ac-tile__cta">Manage devices <mat-icon>arrow_forward</mat-icon></span>
|
||||
}
|
||||
<span class="ac-type-pill ac-tile__type">Type · TOKEN</span>
|
||||
<span class="ac-type-pill ac-tile__type">Type · DSC</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -396,17 +396,28 @@
|
||||
</aside>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="ac-otp-done">
|
||||
<mat-icon>check_circle</mat-icon>
|
||||
<div class="ac-otp-done" [class.is-disabled]="!sms.mfaEnabled">
|
||||
<mat-icon>{{ sms.mfaEnabled ? 'check_circle' : 'pause_circle' }}</mat-icon>
|
||||
<div>
|
||||
<strong>SMS OTP active</strong>
|
||||
<span
|
||||
>OTP will be sent to <code>+91 {{ sms.destination }}</code> on every sign-in.</span
|
||||
>
|
||||
<strong>{{ sms.mfaEnabled ? 'SMS OTP active' : 'SMS OTP verified · 2FA disabled' }}</strong>
|
||||
<span>
|
||||
Number on file: <code>+91 {{ sms.destination }}</code>
|
||||
@if (sms.mfaEnabled) {
|
||||
— OTP will be sent on every sign-in.
|
||||
} @else {
|
||||
— re-enable any time without re-verifying.
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
<button mat-stroked-button color="warn" (click)="disableOtpChannel(sms)">
|
||||
<mat-icon>power_settings_new</mat-icon> Disable
|
||||
</button>
|
||||
@if (sms.mfaEnabled) {
|
||||
<button mat-stroked-button color="warn" [disabled]="sms.busy" (click)="disableOtpChannel(sms)">
|
||||
<mat-icon>power_settings_new</mat-icon> Disable
|
||||
</button>
|
||||
} @else {
|
||||
<button mat-flat-button color="primary" [disabled]="sms.busy" (click)="enableOtpChannel(sms)">
|
||||
<mat-icon>check_circle</mat-icon> Enable for 2FA
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -566,17 +577,28 @@
|
||||
</aside>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="ac-otp-done">
|
||||
<mat-icon>check_circle</mat-icon>
|
||||
<div class="ac-otp-done" [class.is-disabled]="!email.mfaEnabled">
|
||||
<mat-icon>{{ email.mfaEnabled ? 'check_circle' : 'pause_circle' }}</mat-icon>
|
||||
<div>
|
||||
<strong>Email OTP active</strong>
|
||||
<span
|
||||
>OTP will be sent to <code>{{ email.destination }}</code> on every sign-in.</span
|
||||
>
|
||||
<strong>{{ email.mfaEnabled ? 'Email OTP active' : 'Email OTP verified · 2FA disabled' }}</strong>
|
||||
<span>
|
||||
Address on file: <code>{{ email.destination }}</code>
|
||||
@if (email.mfaEnabled) {
|
||||
— OTP will be sent on every sign-in.
|
||||
} @else {
|
||||
— re-enable any time without re-verifying.
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
<button mat-stroked-button color="warn" (click)="disableOtpChannel(email)">
|
||||
<mat-icon>power_settings_new</mat-icon> Disable
|
||||
</button>
|
||||
@if (email.mfaEnabled) {
|
||||
<button mat-stroked-button color="warn" [disabled]="email.busy" (click)="disableOtpChannel(email)">
|
||||
<mat-icon>power_settings_new</mat-icon> Disable
|
||||
</button>
|
||||
} @else {
|
||||
<button mat-flat-button color="primary" [disabled]="email.busy" (click)="enableOtpChannel(email)">
|
||||
<mat-icon>check_circle</mat-icon> Enable for 2FA
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -697,7 +719,7 @@
|
||||
<div class="ac-panel__icon ac-tile__icon--amber"><mat-icon>memory</mat-icon></div>
|
||||
<div class="ac-panel__title">
|
||||
<h3>Hardware Token</h3>
|
||||
<span class="ac-type-pill">Type · TOKEN</span>
|
||||
<span class="ac-type-pill">Type · DSC</span>
|
||||
</div>
|
||||
<span
|
||||
class="ac-state-pill"
|
||||
@@ -828,11 +850,30 @@
|
||||
<fa-icon icon="file-signature" class="ac-hw-btn-icon"></fa-icon>
|
||||
Map DSC
|
||||
</button>
|
||||
} @else if (currentHwItem.mfaEnabled) {
|
||||
<button
|
||||
mat-stroked-button
|
||||
color="warn"
|
||||
[disabled]="currentHwItem.busy"
|
||||
(click)="disableDscMfa(currentHwItem)"
|
||||
>
|
||||
<fa-icon icon="ban" class="ac-hw-btn-icon"></fa-icon>
|
||||
Disable for 2FA
|
||||
</button>
|
||||
<button
|
||||
mat-stroked-button
|
||||
class="ac-hw-revoke-btn"
|
||||
[disabled]="currentHwItem.busy"
|
||||
(click)="revokeDsc(currentHwItem)"
|
||||
>
|
||||
<fa-icon icon="trash" class="ac-hw-btn-icon"></fa-icon>
|
||||
Revoke DSC
|
||||
</button>
|
||||
} @else {
|
||||
<button
|
||||
mat-flat-button
|
||||
color="primary"
|
||||
[disabled]="currentHwItem.mfaEnabled || currentHwItem.busy"
|
||||
[disabled]="currentHwItem.busy"
|
||||
(click)="enableDscMfa(currentHwItem)"
|
||||
>
|
||||
<fa-icon icon="check-circle" class="ac-hw-btn-icon"></fa-icon>
|
||||
@@ -840,12 +881,12 @@
|
||||
</button>
|
||||
<button
|
||||
mat-stroked-button
|
||||
color="warn"
|
||||
[disabled]="!currentHwItem.mfaEnabled || currentHwItem.busy"
|
||||
(click)="disableDscMfa(currentHwItem)"
|
||||
class="ac-hw-revoke-btn"
|
||||
[disabled]="currentHwItem.busy"
|
||||
(click)="revokeDsc(currentHwItem)"
|
||||
>
|
||||
<fa-icon icon="ban" class="ac-hw-btn-icon"></fa-icon>
|
||||
Disable for 2FA
|
||||
<fa-icon icon="trash" class="ac-hw-btn-icon"></fa-icon>
|
||||
Revoke DSC
|
||||
</button>
|
||||
}
|
||||
} @else {
|
||||
|
||||
@@ -959,6 +959,21 @@
|
||||
font-size: 11.5px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Verified but 2FA disabled — muted amber to read as "paused", not "off". */
|
||||
&.is-disabled {
|
||||
background: #fffbeb;
|
||||
border-color: #fde68a;
|
||||
color: #78350f;
|
||||
|
||||
> mat-icon {
|
||||
color: #d97706;
|
||||
}
|
||||
|
||||
> div code {
|
||||
border-color: #fde68a;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ac-ff {
|
||||
|
||||
@@ -23,6 +23,9 @@ 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';
|
||||
import { RevokeDscDialogComponent } from 'app/users/dsc/revoke-dsc-dialog/revoke-dsc-dialog.component';
|
||||
import { SuccessDialogComponent } from 'app/shared/success-dialog/success-dialog.component';
|
||||
import { ErrorDialogComponent } from 'app/shared/error-dialog/error-dialog.component';
|
||||
|
||||
type HwSubKind = 'smart-card' | 'digital-signature' | 'biometric';
|
||||
type MethodKey = 'sms' | 'email' | 'totp' | 'hardware';
|
||||
@@ -35,14 +38,26 @@ interface OtpChannelState {
|
||||
otp: string;
|
||||
otpSent: boolean;
|
||||
resendIn: number;
|
||||
/**
|
||||
* True once the destination has passed an OTP challenge at least once.
|
||||
* This is a permanent attestation of the destination — disabling 2FA
|
||||
* does NOT reset this. To clear, the user must explicitly "Change".
|
||||
*/
|
||||
verified: boolean;
|
||||
/**
|
||||
* True when the channel is currently enforcing 2FA at sign-in.
|
||||
* Independent of `verified`: a destination can be verified but
|
||||
* temporarily disabled (factor row has disabled_on set), and later
|
||||
* re-enabled without going through OTP again.
|
||||
*/
|
||||
mfaEnabled: boolean;
|
||||
errorMsg: string;
|
||||
busy: boolean;
|
||||
/** otpId returned from the OTP-generate endpoint, used by verify */
|
||||
otpId: number | null;
|
||||
/** verifiedOn timestamp from the server (ISO string) */
|
||||
verifiedOn: string;
|
||||
/** Persisted factor id from m_user_mfa_factor */
|
||||
/** Persisted factor id from m_user_mfa_factor (null when no active factor row). */
|
||||
factorId: number | null;
|
||||
}
|
||||
|
||||
@@ -67,6 +82,10 @@ interface HardwareItem {
|
||||
busy: boolean;
|
||||
/** DSC-only: whether the mapped DSC is currently enabled as a 2FA factor. */
|
||||
mfaEnabled?: boolean;
|
||||
/** DSC-only: persisted MFA factor id when enabled (used for disable). */
|
||||
factorId?: number | null;
|
||||
/** DSC-only: id of the m_user_dsc row (needed for revoke). */
|
||||
dscId?: number | null;
|
||||
}
|
||||
|
||||
interface PersistedPrefs {
|
||||
@@ -294,12 +313,22 @@ export class AccessControlComponent implements OnInit, OnDestroy {
|
||||
} else if (f.factorType === 'TOTP' && f.verified) {
|
||||
this.totp.secret = f.secret || this.totp.secret;
|
||||
this.totp.verified = true;
|
||||
} else if (f.factorType === 'TOKEN') {
|
||||
const item = this.hardware.find((h) => h.serial === f.serial);
|
||||
} else if (f.factorType === 'DSC') {
|
||||
// Match by serial, falling back to the DSC tile if no serial match
|
||||
// (DSC is the only hardware kind backed by the factor API today).
|
||||
// A factor with disabled_on set is NOT currently enforcing 2FA —
|
||||
// mapping (m_user_dsc cert is ACTIVE) and 2FA-enabled (factor row
|
||||
// exists with disabled_on IS NULL) are two independent states.
|
||||
const item =
|
||||
this.hardware.find((h) => h.serial === f.serial) ||
|
||||
this.hardware.find((h) => h.kind === 'digital-signature');
|
||||
if (item) {
|
||||
item.registered = true;
|
||||
item.serial = f.serial || item.serial;
|
||||
item.registeredAt = f.verifiedOn || item.registeredAt;
|
||||
const isActive = !f.disabledOn && f.verified !== false;
|
||||
item.mfaEnabled = isActive;
|
||||
item.factorId = isActive ? (f.id ?? null) : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -319,10 +348,12 @@ export class AccessControlComponent implements OnInit, OnDestroy {
|
||||
/** Wipe every factor's verified / registered flags before re-applying API truth. */
|
||||
private resetAllFactors(): void {
|
||||
this.sms.verified = false;
|
||||
this.sms.mfaEnabled = false;
|
||||
this.sms.destinationConfirmed = false;
|
||||
this.sms.factorId = null;
|
||||
this.sms.verifiedOn = '';
|
||||
this.email.verified = false;
|
||||
this.email.mfaEnabled = false;
|
||||
this.email.destinationConfirmed = false;
|
||||
this.email.factorId = null;
|
||||
this.email.verifiedOn = '';
|
||||
@@ -332,14 +363,22 @@ export class AccessControlComponent implements OnInit, OnDestroy {
|
||||
h.registered = false;
|
||||
h.serial = '';
|
||||
h.registeredAt = '';
|
||||
h.mfaEnabled = false;
|
||||
h.factorId = null;
|
||||
h.dscId = null;
|
||||
}
|
||||
}
|
||||
|
||||
private applyFactor(channel: OtpChannelState, f: MfaFactor): void {
|
||||
if (f.destination) channel.destination = f.destination;
|
||||
// `verified` is the permanent attestation that this destination passed
|
||||
// OTP at least once. `mfaEnabled` is the live 2FA toggle — false when
|
||||
// the factor row has disabled_on set.
|
||||
channel.verified = !!f.verified;
|
||||
channel.destinationConfirmed = !!f.verified;
|
||||
channel.factorId = f.id ?? null;
|
||||
const isActive = !f.disabledOn && !!f.verified;
|
||||
channel.mfaEnabled = isActive;
|
||||
channel.factorId = isActive ? (f.id ?? null) : null;
|
||||
channel.verifiedOn = f.verifiedOn || '';
|
||||
}
|
||||
|
||||
@@ -434,6 +473,10 @@ export class AccessControlComponent implements OnInit, OnDestroy {
|
||||
const finishVerified = (saved?: MfaFactor) => {
|
||||
channel.busy = false;
|
||||
channel.verified = true;
|
||||
// Initial verification auto-enables the factor for 2FA — that's
|
||||
// the user's intent when they walked through the OTP setup flow.
|
||||
// Subsequent disable/enable is independent of `verified`.
|
||||
channel.mfaEnabled = true;
|
||||
channel.otpSent = false;
|
||||
channel.resendIn = 0;
|
||||
channel.factorId = saved?.id ?? null;
|
||||
@@ -473,6 +516,41 @@ export class AccessControlComponent implements OnInit, OnDestroy {
|
||||
channel.otpId = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-enable a SMS/Email channel whose destination was previously verified
|
||||
* but is currently disabled (factor row has disabled_on set). This skips
|
||||
* OTP re-verification — the attestation persists via `verified=true`.
|
||||
* Saves a fresh factor row server-side and adopts its new id.
|
||||
*/
|
||||
enableOtpChannel(channel: OtpChannelState): void {
|
||||
if (!channel.verified || !channel.destination) {
|
||||
this.showError('Verify your destination with an OTP before enabling 2FA.');
|
||||
return;
|
||||
}
|
||||
const label = channel.typeCode === 'SMS' ? 'SMS OTP' : 'Email OTP';
|
||||
const finish = (saved?: MfaFactor) => {
|
||||
channel.busy = false;
|
||||
channel.mfaEnabled = true;
|
||||
channel.factorId = saved?.id ?? channel.factorId ?? null;
|
||||
this.savePrefs();
|
||||
this.showSuccess(`${label} enabled for 2FA`, `${label} will be required at sign-in for ${this.username}.`);
|
||||
};
|
||||
if (!this.mfaFactorsApiAvailable) {
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
channel.busy = true;
|
||||
this.accessControlService.saveFactor({ factorType: channel.typeCode, destination: channel.destination }).subscribe({
|
||||
next: (saved) => finish(saved),
|
||||
error: (err) => {
|
||||
channel.busy = false;
|
||||
this.showError(
|
||||
err?.error?.defaultUserMessage || err?.error?.message || `Could not enable ${label} as a 2FA factor.`
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
disableOtpChannel(channel: OtpChannelState): void {
|
||||
const label = channel.typeCode === 'SMS' ? 'SMS OTP' : 'Email OTP';
|
||||
const destination =
|
||||
@@ -490,19 +568,17 @@ export class AccessControlComponent implements OnInit, OnDestroy {
|
||||
.afterClosed()
|
||||
.subscribe((response: any) => {
|
||||
if (!response?.confirmed) return;
|
||||
// Disable only flips the 2FA toggle. The destination + verified
|
||||
// attestation stay so the user can re-enable later without
|
||||
// re-running OTP. To wipe destination, the user must use "Change".
|
||||
const finish = () => {
|
||||
channel.verified = false;
|
||||
channel.otp = '';
|
||||
channel.otpSent = false;
|
||||
channel.destinationConfirmed = false;
|
||||
channel.errorMsg = '';
|
||||
channel.resendIn = 0;
|
||||
channel.otpId = null;
|
||||
channel.mfaEnabled = false;
|
||||
channel.factorId = null;
|
||||
channel.verifiedOn = '';
|
||||
channel.busy = false;
|
||||
this.savePrefs();
|
||||
};
|
||||
if (this.mfaFactorsApiAvailable && channel.factorId != null) {
|
||||
channel.busy = true;
|
||||
this.accessControlService.disableFactor(channel.factorId).subscribe({
|
||||
next: () => finish(),
|
||||
error: () => finish()
|
||||
@@ -646,6 +722,7 @@ export class AccessControlComponent implements OnInit, OnDestroy {
|
||||
if (!result?.enrolled) return;
|
||||
const dsc = result.dsc;
|
||||
item.registered = true;
|
||||
item.dscId = dsc?.id ?? null;
|
||||
item.serial = dsc?.dscSerialNoFull || dsc?.dscSerialNoMasked || '';
|
||||
item.registeredAt = new Date().toLocaleString();
|
||||
item.mfaEnabled = false;
|
||||
@@ -654,13 +731,137 @@ export class AccessControlComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
enableDscMfa(item: HardwareItem): void {
|
||||
item.mfaEnabled = true;
|
||||
this.savePrefs();
|
||||
if (!item.registered || !item.serial) {
|
||||
this.showError('Map a DSC before enabling it as a 2FA factor.');
|
||||
return;
|
||||
}
|
||||
const finish = (saved?: MfaFactor) => {
|
||||
item.busy = false;
|
||||
item.mfaEnabled = true;
|
||||
item.factorId = saved?.id ?? item.factorId ?? null;
|
||||
this.savePrefs();
|
||||
this.showSuccess(
|
||||
'DSC enabled for 2FA',
|
||||
`Sign-in for ${this.username} will now require this Digital Signature token.`
|
||||
);
|
||||
};
|
||||
if (!this.mfaFactorsApiAvailable) {
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
item.busy = true;
|
||||
this.accessControlService.saveFactor({ factorType: 'DSC', serial: item.serial }).subscribe({
|
||||
next: (saved) => finish(saved),
|
||||
error: (err) => {
|
||||
item.busy = false;
|
||||
this.showError(
|
||||
err?.error?.defaultUserMessage || err?.error?.message || 'Could not enable DSC as a 2FA factor.'
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
disableDscMfa(item: HardwareItem): void {
|
||||
item.mfaEnabled = false;
|
||||
this.savePrefs();
|
||||
this.dialog
|
||||
.open(DisableConfirmDialogComponent, {
|
||||
width: '440px',
|
||||
autoFocus: false,
|
||||
data: {
|
||||
title: 'Disable DSC for 2FA',
|
||||
message:
|
||||
'The Digital Signature token will no longer be required at sign-in. The mapping itself stays in place — you can re-enable it any time.',
|
||||
detail: item.serial ? `Currently active for serial ${item.serial}` : ''
|
||||
}
|
||||
})
|
||||
.afterClosed()
|
||||
.subscribe((response: any) => {
|
||||
if (!response?.confirmed) return;
|
||||
const finish = () => {
|
||||
item.busy = false;
|
||||
item.mfaEnabled = false;
|
||||
item.factorId = null;
|
||||
this.savePrefs();
|
||||
this.showSuccess(
|
||||
'DSC disabled for 2FA',
|
||||
'The token is no longer used as a second factor. Sign-in will fall back to your password only.'
|
||||
);
|
||||
};
|
||||
if (this.mfaFactorsApiAvailable && item.factorId != null) {
|
||||
item.busy = true;
|
||||
this.accessControlService.disableFactor(item.factorId).subscribe({
|
||||
next: () => finish(),
|
||||
error: () => finish() // keep UX consistent — local state already reflects the user's intent
|
||||
});
|
||||
} else {
|
||||
finish();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke a mapped DSC. Opens the existing reason-capture dialog (which calls
|
||||
* /v1/dsc/{id} DELETE itself), then on success disables the MFA factor if
|
||||
* it was enabled and clears the tile back to the unmapped state. Mapping
|
||||
* and 2FA-enabled are independent — both must be cleared on revoke.
|
||||
*/
|
||||
revokeDsc(item: HardwareItem): void {
|
||||
if (!item.dscId) {
|
||||
this.showError('Cannot revoke — DSC certificate id is missing. Reload the page and try again.');
|
||||
return;
|
||||
}
|
||||
// The dialog only needs id + a serial to display; build a minimal stub
|
||||
// rather than fetching the full BankUserDsc just to surface a confirm.
|
||||
const dscStub = {
|
||||
id: item.dscId,
|
||||
dscSerialNoMasked: item.serial,
|
||||
dscSerialNoFull: item.serial
|
||||
} as any;
|
||||
const ref = this.dialog.open(RevokeDscDialogComponent, {
|
||||
width: '460px',
|
||||
autoFocus: false,
|
||||
disableClose: true,
|
||||
data: { dsc: dscStub }
|
||||
});
|
||||
ref.afterClosed().subscribe((result: any) => {
|
||||
if (!result?.revoked) return;
|
||||
const clearLocal = () => {
|
||||
item.busy = false;
|
||||
item.registered = false;
|
||||
item.serial = '';
|
||||
item.registeredAt = '';
|
||||
item.mfaEnabled = false;
|
||||
item.factorId = null;
|
||||
item.dscId = null;
|
||||
this.selectedHwKind = null;
|
||||
this.savePrefs();
|
||||
this.showSuccess(
|
||||
'DSC revoked',
|
||||
'The certificate has been revoked and unbound from your account. You can map a new DSC any time.'
|
||||
);
|
||||
};
|
||||
// If 2FA was enabled, also disable the factor row so the next sign-in
|
||||
// doesn't try to enforce a now-revoked certificate.
|
||||
if (this.mfaFactorsApiAvailable && item.factorId != null) {
|
||||
item.busy = true;
|
||||
this.accessControlService.disableFactor(item.factorId).subscribe({
|
||||
next: () => clearLocal(),
|
||||
error: () => clearLocal() // revoke succeeded; factor cleanup is best-effort
|
||||
});
|
||||
} else {
|
||||
clearLocal();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private showSuccess(title: string, message: string): void {
|
||||
this.dialog.open(SuccessDialogComponent, {
|
||||
data: { title, message, buttonLabel: 'OK' },
|
||||
panelClass: 'sd-panel'
|
||||
});
|
||||
}
|
||||
|
||||
private showError(message: string): void {
|
||||
this.dialog.open(ErrorDialogComponent, { data: message });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -676,6 +877,7 @@ export class AccessControlComponent implements OnInit, OnDestroy {
|
||||
const active = (dscs || []).find((d) => (d?.certStatus || '').toUpperCase() === 'ACTIVE');
|
||||
if (active) {
|
||||
dscItem.registered = true;
|
||||
dscItem.dscId = active.id;
|
||||
dscItem.serial = active.dscSerialNoMasked || active.dscSerialNoFull || dscItem.serial;
|
||||
dscItem.registeredAt = dscItem.registeredAt || new Date().toLocaleString();
|
||||
}
|
||||
@@ -695,17 +897,31 @@ export class AccessControlComponent implements OnInit, OnDestroy {
|
||||
|
||||
// ───────────────────────────── Aggregate state
|
||||
|
||||
/** True when at least one method (any type) is verified/registered */
|
||||
/**
|
||||
* True when at least one method is currently enforcing 2FA. For SMS/Email
|
||||
* and Hardware, "active" requires the live 2FA toggle (`mfaEnabled`) — a
|
||||
* verified-but-disabled destination does NOT count. TOTP is single-state.
|
||||
*/
|
||||
get anyMethodActive(): boolean {
|
||||
return this.sms.verified || this.email.verified || this.totp.verified || this.hardware.some((h) => h.registered);
|
||||
return (
|
||||
(this.sms.verified && this.sms.mfaEnabled) ||
|
||||
(this.email.verified && this.email.mfaEnabled) ||
|
||||
this.totp.verified ||
|
||||
this.hardware.some((h) => h.registered && h.mfaEnabled === true)
|
||||
);
|
||||
}
|
||||
|
||||
/** Returns the single currently-active MFA method, or null if none. */
|
||||
/**
|
||||
* Returns the single currently-active MFA method, or null if none.
|
||||
* "Active" means the method is currently enforcing 2FA at sign-in. A
|
||||
* verified-but-disabled SMS/Email channel, or a mapped-but-not-enabled
|
||||
* DSC, does NOT lock out other methods.
|
||||
*/
|
||||
get activeMethod(): MethodKey | null {
|
||||
if (this.sms.verified) return 'sms';
|
||||
if (this.email.verified) return 'email';
|
||||
if (this.sms.verified && this.sms.mfaEnabled) return 'sms';
|
||||
if (this.email.verified && this.email.mfaEnabled) return 'email';
|
||||
if (this.totp.verified) return 'totp';
|
||||
if (this.hardware.some((h) => h.registered)) return 'hardware';
|
||||
if (this.hardware.some((h) => h.registered && h.mfaEnabled === true)) return 'hardware';
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -803,6 +1019,7 @@ export class AccessControlComponent implements OnInit, OnDestroy {
|
||||
otpSent: false,
|
||||
resendIn: 0,
|
||||
verified: false,
|
||||
mfaEnabled: false,
|
||||
errorMsg: '',
|
||||
busy: false,
|
||||
otpId: null,
|
||||
|
||||
@@ -22,7 +22,7 @@ export interface OtpVerifyResponse {
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export type MfaFactorType = 'SMS' | 'EMAIL' | 'TOTP' | 'TOKEN';
|
||||
export type MfaFactorType = 'SMS' | 'EMAIL' | 'TOTP' | 'TOKEN' | 'DSC';
|
||||
|
||||
export interface MfaFactor {
|
||||
id?: number;
|
||||
@@ -32,6 +32,8 @@ export interface MfaFactor {
|
||||
serial?: string;
|
||||
verified: boolean;
|
||||
verifiedOn?: string;
|
||||
/** ISO timestamp when the factor was logically disabled — when set, the factor is NOT active. */
|
||||
disabledOn?: string;
|
||||
otpLogId?: number;
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ import { EntityNotesTabComponent } from '../../../shared/tabs/entity-notes-tab/e
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-notes-tab',
|
||||
selector: 'mifosx-centers-notes-tab',
|
||||
templateUrl: './notes-tab.component.html',
|
||||
styleUrls: ['./notes-tab.component.scss'],
|
||||
imports: [
|
||||
|
||||
+7
-1
@@ -298,9 +298,15 @@
|
||||
<mat-label>Relationship</mat-label>
|
||||
<mat-select required formControlName="relationshipId">
|
||||
@for (rel of data.options.relationshipIdOptions; track rel) {
|
||||
<mat-option [value]="rel.id">{{ rel.name }}</mat-option>
|
||||
<mat-option [value]="rel.id" [disabled]="disabledRelationshipIds.has(rel.id)">
|
||||
{{ rel.name }}
|
||||
@if (disabledRelationshipIds.has(rel.id)) {
|
||||
<span class="fm-rel-taken">· already added</span>
|
||||
}
|
||||
</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
<mat-hint>Father, Mother and Spouse can only be added once.</mat-hint>
|
||||
</mat-form-field>
|
||||
|
||||
@if (entryMode === 'manual') {
|
||||
|
||||
+8
@@ -28,6 +28,14 @@
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* Suffix shown next to disabled relationship options (Father/Mother/Spouse already added). */
|
||||
.fm-rel-taken {
|
||||
margin-left: 4px;
|
||||
font-size: 11px;
|
||||
font-style: italic;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.fm-mode-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
+36
@@ -86,6 +86,42 @@ export class ClientFamilyMemberDialogComponent implements OnInit {
|
||||
return rel?.name?.toUpperCase() === 'SPOUSE';
|
||||
}
|
||||
|
||||
/**
|
||||
* Relationships that may appear at most once per client. Names compared
|
||||
* case-insensitively against the option list returned by the backend.
|
||||
*/
|
||||
private static readonly UNIQUE_RELATIONSHIPS = new Set([
|
||||
'FATHER',
|
||||
'MOTHER',
|
||||
'SPOUSE'
|
||||
]);
|
||||
|
||||
/**
|
||||
* Set of relationshipId values that are already taken by another family
|
||||
* member and should therefore be disabled in the dropdown. The member
|
||||
* currently being edited is excluded so its existing relationship stays
|
||||
* selectable. Empty set in Add mode when no unique relations exist yet.
|
||||
*/
|
||||
get disabledRelationshipIds(): Set<number> {
|
||||
const disabled = new Set<number>();
|
||||
const existing = this.data?.existingMembers || [];
|
||||
const options = this.data?.options?.relationshipIdOptions || [];
|
||||
if (!existing.length || !options.length) return disabled;
|
||||
const editingId = this.data?.context === 'Edit' ? this.data?.member?.id : undefined;
|
||||
for (const m of existing) {
|
||||
// Skip the member being edited so it can keep its current selection.
|
||||
if (editingId && m.id === editingId) continue;
|
||||
const relId = typeof m.relationshipId === 'object' ? m.relationshipId?.id : m.relationshipId;
|
||||
if (relId == null) continue;
|
||||
const rel = options.find((r: any) => r.id === relId);
|
||||
const name = (rel?.name || '').toUpperCase();
|
||||
if (ClientFamilyMemberDialogComponent.UNIQUE_RELATIONSHIPS.has(name)) {
|
||||
disabled.add(relId);
|
||||
}
|
||||
}
|
||||
return disabled;
|
||||
}
|
||||
|
||||
/** Whether dialog is in View-only mode */
|
||||
get isViewMode(): boolean {
|
||||
return this.data.context === 'View';
|
||||
|
||||
+17
-3
@@ -132,7 +132,11 @@ export class ClientFamilyMembersStepComponent implements OnInit {
|
||||
|
||||
addFamilyMember() {
|
||||
const ref = this.dialog.open(ClientFamilyMemberDialogComponent, {
|
||||
data: { context: 'Add', options: this.clientTemplate.familyMemberOptions },
|
||||
data: {
|
||||
context: 'Add',
|
||||
options: this.clientTemplate.familyMemberOptions,
|
||||
existingMembers: this.clientFamilyMembers
|
||||
},
|
||||
width: '640px',
|
||||
maxWidth: '95vw',
|
||||
panelClass: 'fm-dialog-panel'
|
||||
@@ -146,7 +150,12 @@ export class ClientFamilyMembersStepComponent implements OnInit {
|
||||
|
||||
viewFamilyMember(member: any) {
|
||||
this.dialog.open(ClientFamilyMemberDialogComponent, {
|
||||
data: { context: 'View', member, options: this.clientTemplate.familyMemberOptions },
|
||||
data: {
|
||||
context: 'View',
|
||||
member,
|
||||
options: this.clientTemplate.familyMemberOptions,
|
||||
existingMembers: this.clientFamilyMembers
|
||||
},
|
||||
width: '640px',
|
||||
maxWidth: '95vw',
|
||||
panelClass: 'fm-dialog-panel'
|
||||
@@ -155,7 +164,12 @@ export class ClientFamilyMembersStepComponent implements OnInit {
|
||||
|
||||
editFamilyMember(member: any, index: number) {
|
||||
const ref = this.dialog.open(ClientFamilyMemberDialogComponent, {
|
||||
data: { context: 'Edit', member, options: this.clientTemplate.familyMemberOptions },
|
||||
data: {
|
||||
context: 'Edit',
|
||||
member,
|
||||
options: this.clientTemplate.familyMemberOptions,
|
||||
existingMembers: this.clientFamilyMembers
|
||||
},
|
||||
width: '640px',
|
||||
maxWidth: '95vw',
|
||||
panelClass: 'fm-dialog-panel'
|
||||
|
||||
+1
-1
@@ -23,7 +23,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
* Unassign staff dialog component.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-unassign-staff-dialog',
|
||||
selector: 'mifosx-clients-unassign-staff-dialog',
|
||||
templateUrl: './unassign-staff-dialog.component.html',
|
||||
styleUrls: ['./unassign-staff-dialog.component.scss'],
|
||||
imports: [
|
||||
|
||||
@@ -22,7 +22,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
* Notes Tab Component
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-notes-tab',
|
||||
selector: 'mifosx-clients-notes-tab',
|
||||
templateUrl: './notes-tab.component.html',
|
||||
styleUrls: ['./notes-tab.component.scss'],
|
||||
imports: [
|
||||
|
||||
@@ -14,7 +14,7 @@ import { EntityNotesTabComponent } from '../../../shared/tabs/entity-notes-tab/e
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-notes-tab',
|
||||
selector: 'mifosx-current-account-notes-tab',
|
||||
templateUrl: './notes-tab.component.html',
|
||||
styleUrls: ['./notes-tab.component.scss'],
|
||||
imports: [
|
||||
|
||||
+1
-1
@@ -23,7 +23,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
* Unassign staff dialog component.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-unassign-staff-dialog',
|
||||
selector: 'mifosx-groups-unassign-staff-dialog',
|
||||
templateUrl: './unassign-staff-dialog.component.html',
|
||||
styleUrls: ['./unassign-staff-dialog.component.scss'],
|
||||
imports: [
|
||||
|
||||
@@ -22,7 +22,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
* Groups Notes Tab Component.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-notes-tab',
|
||||
selector: 'mifosx-groups-notes-tab',
|
||||
templateUrl: './notes-tab.component.html',
|
||||
styleUrls: ['./notes-tab.component.scss'],
|
||||
imports: [
|
||||
|
||||
@@ -13,6 +13,15 @@
|
||||
<div class="welcome-greeting">
|
||||
<h2>
|
||||
{{ 'labels.text.Welcome' | translate }}, <strong>{{ username }}</strong>
|
||||
@if (dscMapped) {
|
||||
<span class="dsc-badge" [matTooltip]="'DSC verified · serial ' + dscSerial" matTooltipPosition="right">
|
||||
<span class="dsc-badge__shield">
|
||||
<fa-icon icon="shield-alt"></fa-icon>
|
||||
<span class="dsc-badge__check"><fa-icon icon="check"></fa-icon></span>
|
||||
</span>
|
||||
<span class="dsc-badge__label">DSC Verified</span>
|
||||
</span>
|
||||
}
|
||||
</h2>
|
||||
<p class="welcome-sub">{{ tenant | titlecase }} · CBSNexora Core Banking Platform</p>
|
||||
</div>
|
||||
|
||||
@@ -54,6 +54,10 @@ $accent: #0ea5e9;
|
||||
font-size: 20px;
|
||||
font-weight: 400;
|
||||
color: rgb(255 255 255 / 90%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
|
||||
strong {
|
||||
font-weight: 700;
|
||||
@@ -62,6 +66,70 @@ $accent: #0ea5e9;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── DSC Verified badge (welcome banner) ── */
|
||||
.dsc-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-left: 10px;
|
||||
padding: 4px 12px 4px 6px;
|
||||
background: linear-gradient(135deg, rgb(34 197 94 / 18%) 0%, rgb(34 197 94 / 8%) 100%);
|
||||
border: 1px solid rgb(74 222 128 / 35%);
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
color: #bbf7d0;
|
||||
cursor: default;
|
||||
transition:
|
||||
transform 0.18s ease,
|
||||
background 0.18s ease;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-1px);
|
||||
background: linear-gradient(135deg, rgb(34 197 94 / 28%) 0%, rgb(34 197 94 / 14%) 100%);
|
||||
}
|
||||
|
||||
&__shield {
|
||||
position: relative;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgb(22 163 74 / 90%);
|
||||
border-radius: 6px;
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 1px 3px rgb(15 118 50 / 35%);
|
||||
}
|
||||
|
||||
&__check {
|
||||
position: absolute;
|
||||
right: -4px;
|
||||
bottom: -4px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
background: #16a34a;
|
||||
color: #fff;
|
||||
border: 2px solid #1a5276; /* matches the banner's right edge so the ring blends in */
|
||||
border-radius: 50%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 8px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
&__label {
|
||||
color: #ecfdf5;
|
||||
text-transform: uppercase;
|
||||
font-size: 10.5px;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
}
|
||||
|
||||
.welcome-sub {
|
||||
margin: 4px 0 0;
|
||||
font-size: 12px;
|
||||
|
||||
@@ -25,11 +25,13 @@ import { AuthenticationService } from '../core/authentication/authentication.ser
|
||||
import { PopoverService } from '../configuration-wizard/popover/popover.service';
|
||||
import { ConfigurationWizardService } from '../configuration-wizard/configuration-wizard.service';
|
||||
import { SettingsService } from 'app/settings/settings.service';
|
||||
import { DscService } from 'app/users/dsc/dsc.service';
|
||||
|
||||
/** Custom Components */
|
||||
import { NextStepDialogComponent } from '../configuration-wizard/next-step-dialog/next-step-dialog.component';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { MatAutocompleteTrigger, MatAutocomplete, MatOption } from '@angular/material/autocomplete';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { AsyncPipe, DatePipe, TitleCasePipe } from '@angular/common';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
|
||||
@@ -45,6 +47,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
FaIconComponent,
|
||||
MatAutocompleteTrigger,
|
||||
MatAutocomplete,
|
||||
MatTooltip,
|
||||
AsyncPipe,
|
||||
DatePipe,
|
||||
TitleCasePipe
|
||||
@@ -58,6 +61,7 @@ export class HomeComponent implements OnInit, AfterViewInit {
|
||||
private configurationWizardService = inject(ConfigurationWizardService);
|
||||
private popoverService = inject(PopoverService);
|
||||
private settingsService = inject(SettingsService);
|
||||
private dscService = inject(DscService);
|
||||
|
||||
/** Username of authenticated user. */
|
||||
username: string;
|
||||
@@ -65,6 +69,10 @@ export class HomeComponent implements OnInit, AfterViewInit {
|
||||
tenant: string;
|
||||
/** Today's date */
|
||||
today = new Date();
|
||||
/** True when the signed-in user has at least one ACTIVE DSC certificate mapped. */
|
||||
dscMapped = false;
|
||||
/** Masked serial of the ACTIVE DSC, surfaced in the welcome badge tooltip. */
|
||||
dscSerial = '';
|
||||
/** Activity Form. */
|
||||
activityForm: any;
|
||||
/** Search Text. */
|
||||
@@ -95,6 +103,7 @@ export class HomeComponent implements OnInit, AfterViewInit {
|
||||
this.username = credentials.username;
|
||||
this.tenant = this.tenantIdentifier();
|
||||
this.setFilteredActivities();
|
||||
this.loadDscStatus();
|
||||
if (!this.authenticationService.hasDialogBeenShown()) {
|
||||
this.dialog.open(WarningDialogComponent, {
|
||||
disableClose: false,
|
||||
@@ -104,6 +113,26 @@ export class HomeComponent implements OnInit, AfterViewInit {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Surface a small "DSC verified" badge in the welcome banner when the
|
||||
* signed-in user has at least one ACTIVE DSC certificate. Silent on
|
||||
* error / when nothing is mapped — the badge simply doesn't render.
|
||||
*/
|
||||
private loadDscStatus(): void {
|
||||
this.dscService.listMyDscs().subscribe({
|
||||
next: (dscs) => {
|
||||
const active = (dscs || []).find((d) => (d?.certStatus || '').toUpperCase() === 'ACTIVE');
|
||||
if (active) {
|
||||
this.dscMapped = true;
|
||||
this.dscSerial = active.dscSerialNoMasked || active.dscSerialNoFull || '';
|
||||
}
|
||||
},
|
||||
error: () => {
|
||||
// Silent — endpoint may be unavailable; we just don't show the badge.
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets filtered activities for autocomplete.
|
||||
*/
|
||||
|
||||
@@ -18,7 +18,7 @@ import { EntityNotesTabComponent } from '../../../shared/tabs/entity-notes-tab/e
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-notes-tab',
|
||||
selector: 'mifosx-loans-notes-tab',
|
||||
templateUrl: './notes-tab.component.html',
|
||||
styleUrls: ['./notes-tab.component.scss'],
|
||||
imports: [
|
||||
|
||||
@@ -51,8 +51,18 @@ export class TwoFactorAuthenticationComponent implements OnInit, OnDestroy {
|
||||
otpRequested = false;
|
||||
/** Time for which OTP is valid. */
|
||||
tokenValidityTime: number;
|
||||
/** Two factor authentication delivery method form group. */
|
||||
twoFactorAuthenticationDeliveryMethodForm: UntypedFormGroup;
|
||||
/**
|
||||
* Two factor authentication delivery method form group. Initialised at field declaration so
|
||||
* the template can bind to it on the very first change-detection cycle — otherwise Angular's
|
||||
* standalone-signal renderer fires a "Cannot find control" error on the first pass before
|
||||
* ngOnInit has had a chance to build the group, even though the form works fine after init.
|
||||
*/
|
||||
twoFactorAuthenticationDeliveryMethodForm: UntypedFormGroup = new UntypedFormBuilder().group({
|
||||
twoFactorAuthenticationDeliveryMethod: [
|
||||
'',
|
||||
Validators.required
|
||||
]
|
||||
});
|
||||
/** Two factor authentication form group. */
|
||||
twoFactorAuthenticationForm: UntypedFormGroup;
|
||||
/** True if loading. */
|
||||
@@ -81,7 +91,7 @@ export class TwoFactorAuthenticationComponent implements OnInit, OnDestroy {
|
||||
* Gets the delivery methods available to receive OTP.
|
||||
*/
|
||||
ngOnInit() {
|
||||
this.createTwoFactorAuthenticationDeliveryMethodForm();
|
||||
// Delivery-method form is initialised at field declaration — no need to call create() here.
|
||||
// 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({
|
||||
|
||||
@@ -14,7 +14,7 @@ import { EntityNotesTabComponent } from '../../../shared/tabs/entity-notes-tab/e
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-notes-tab',
|
||||
selector: 'mifosx-savings-notes-tab',
|
||||
templateUrl: './notes-tab.component.html',
|
||||
styleUrls: ['./notes-tab.component.scss'],
|
||||
imports: [
|
||||
|
||||
@@ -6,17 +6,19 @@
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<h1 mat-dialog-title>{{ 'Error Log' | translate }}</h1>
|
||||
|
||||
<div mat-dialog-content>
|
||||
<mat-dialog-content class="ed-content">
|
||||
<div class="ed-icon">
|
||||
<fa-icon icon="exclamation-circle"></fa-icon>
|
||||
</div>
|
||||
<h2 class="ed-title">We encountered an issue. Please try again.</h2>
|
||||
@if (!showAsCode) {
|
||||
<p>{{ displayData }}</p>
|
||||
<p class="ed-message">{{ displayData }}</p>
|
||||
}
|
||||
@if (showAsCode) {
|
||||
<span [innerHTML]="sanitizedData"></span>
|
||||
<div class="ed-code" [innerHTML]="sanitizedData"></div>
|
||||
}
|
||||
</div>
|
||||
</mat-dialog-content>
|
||||
|
||||
<mat-dialog-actions align="left">
|
||||
<button mat-raised-button mat-dialog-close>{{ 'Cancel' | translate }}</button>
|
||||
<mat-dialog-actions align="center" class="ed-actions">
|
||||
<button mat-flat-button color="warn" mat-dialog-close>OK</button>
|
||||
</mat-dialog-actions>
|
||||
|
||||
@@ -5,3 +5,87 @@
|
||||
* 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;
|
||||
font-family: inherit;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.ed-content {
|
||||
display: flex !important;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
gap: 14px;
|
||||
padding: 32px 28px 8px !important;
|
||||
background: linear-gradient(180deg, #fef2f2 0%, #fff 70%);
|
||||
}
|
||||
|
||||
.ed-icon {
|
||||
width: 76px;
|
||||
height: 76px;
|
||||
border-radius: 50%;
|
||||
background: #fee2e2;
|
||||
color: #dc2626;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 38px;
|
||||
box-shadow: 0 0 0 6px rgb(220 38 38 / 10%);
|
||||
animation: ed-pop 0.32s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
|
||||
@keyframes ed-pop {
|
||||
0% {
|
||||
transform: scale(0.6);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.ed-title {
|
||||
margin: 4px 0 0;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.ed-message {
|
||||
margin: 0;
|
||||
color: #475569;
|
||||
font-size: 14px;
|
||||
line-height: 1.55;
|
||||
max-width: 380px;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.ed-code {
|
||||
width: 100%;
|
||||
max-height: 320px;
|
||||
overflow: auto;
|
||||
text-align: left;
|
||||
background: #0f172a;
|
||||
color: #e2e8f0;
|
||||
border-radius: 8px;
|
||||
padding: 12px 14px;
|
||||
font-family: 'SF Mono', Menlo, Consolas, monospace;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.ed-actions {
|
||||
padding: 16px 24px 22px !important;
|
||||
background: #fff;
|
||||
justify-content: center !important;
|
||||
|
||||
button {
|
||||
min-width: 120px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,13 +11,13 @@ import { Component, OnInit, SecurityContext, inject } from '@angular/core';
|
||||
import {
|
||||
MatDialogRef,
|
||||
MAT_DIALOG_DATA,
|
||||
MatDialogTitle,
|
||||
MatDialogContent,
|
||||
MatDialogActions,
|
||||
MatDialogClose
|
||||
} from '@angular/material/dialog';
|
||||
import { CdkScrollable } from '@angular/cdk/scrolling';
|
||||
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
|
||||
@Component({
|
||||
@@ -26,11 +26,11 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
styleUrls: ['./error-dialog.component.scss'],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
MatDialogTitle,
|
||||
CdkScrollable,
|
||||
MatDialogContent,
|
||||
MatDialogActions,
|
||||
MatDialogClose
|
||||
MatDialogClose,
|
||||
FaIconComponent
|
||||
]
|
||||
})
|
||||
export class ErrorDialogComponent {
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
<!--
|
||||
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/.
|
||||
-->
|
||||
|
||||
<mat-dialog-content class="sd-content">
|
||||
<div class="sd-icon">
|
||||
<fa-icon icon="check-circle"></fa-icon>
|
||||
</div>
|
||||
<h2 class="sd-title">{{ data.title || 'Success' }}</h2>
|
||||
@if (data.message) {
|
||||
<p class="sd-message">{{ data.message }}</p>
|
||||
}
|
||||
</mat-dialog-content>
|
||||
|
||||
<mat-dialog-actions align="center" class="sd-actions">
|
||||
<button mat-flat-button color="primary" mat-dialog-close>
|
||||
{{ data.buttonLabel || 'OK' }}
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* 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: 460px;
|
||||
font-family: inherit;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.sd-content {
|
||||
display: flex !important;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
gap: 14px;
|
||||
padding: 32px 28px 8px !important;
|
||||
background: linear-gradient(180deg, #f0fdf4 0%, #fff 70%);
|
||||
}
|
||||
|
||||
.sd-icon {
|
||||
width: 76px;
|
||||
height: 76px;
|
||||
border-radius: 50%;
|
||||
background: #dcfce7;
|
||||
color: #16a34a;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 38px;
|
||||
box-shadow: 0 0 0 6px rgb(22 163 74 / 10%);
|
||||
animation: sd-pop 0.32s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
|
||||
@keyframes sd-pop {
|
||||
0% {
|
||||
transform: scale(0.6);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.sd-title {
|
||||
margin: 4px 0 0;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.sd-message {
|
||||
margin: 0;
|
||||
color: #475569;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.sd-actions {
|
||||
padding: 16px 24px 22px !important;
|
||||
background: #fff;
|
||||
justify-content: center !important;
|
||||
|
||||
button {
|
||||
min-width: 120px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* 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 {
|
||||
MatDialogRef,
|
||||
MAT_DIALOG_DATA,
|
||||
MatDialogContent,
|
||||
MatDialogActions,
|
||||
MatDialogClose
|
||||
} from '@angular/material/dialog';
|
||||
import { MatButton } from '@angular/material/button';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
|
||||
export interface SuccessDialogData {
|
||||
title?: string;
|
||||
message?: string;
|
||||
buttonLabel?: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-success-dialog',
|
||||
templateUrl: './success-dialog.component.html',
|
||||
styleUrls: ['./success-dialog.component.scss'],
|
||||
imports: [
|
||||
MatDialogContent,
|
||||
MatDialogActions,
|
||||
MatDialogClose,
|
||||
MatButton,
|
||||
FaIconComponent
|
||||
]
|
||||
})
|
||||
export class SuccessDialogComponent {
|
||||
dialogRef = inject<MatDialogRef<SuccessDialogComponent>>(MatDialogRef);
|
||||
data = inject<SuccessDialogData>(MAT_DIALOG_DATA);
|
||||
}
|
||||
@@ -10,13 +10,15 @@
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||
import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
|
||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
|
||||
/** Custom Services */
|
||||
import { UsersService } from '../users.service';
|
||||
import { MatCheckbox } from '@angular/material/checkbox';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
import { SuccessDialogComponent } from 'app/shared/success-dialog/success-dialog.component';
|
||||
import { ErrorDialogComponent } from 'app/shared/error-dialog/error-dialog.component';
|
||||
|
||||
/**
|
||||
* Edit User Component.
|
||||
@@ -28,8 +30,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
MatCheckbox,
|
||||
MatIcon,
|
||||
MatSnackBarModule
|
||||
MatIcon
|
||||
]
|
||||
})
|
||||
export class EditUserComponent implements OnInit {
|
||||
@@ -37,7 +38,7 @@ export class EditUserComponent implements OnInit {
|
||||
private usersService = inject(UsersService);
|
||||
private route = inject(ActivatedRoute);
|
||||
private router = inject(Router);
|
||||
private snackBar = inject(MatSnackBar);
|
||||
private dialog = inject(MatDialog);
|
||||
|
||||
/** User Data */
|
||||
userData: any;
|
||||
@@ -144,23 +145,29 @@ export class EditUserComponent implements OnInit {
|
||||
// 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']
|
||||
const dialogRef = this.dialog.open(SuccessDialogComponent, {
|
||||
data: {
|
||||
title: 'User updated',
|
||||
message: `Changes to ${this.userData.username} have been saved successfully.`,
|
||||
buttonLabel: 'View user'
|
||||
},
|
||||
panelClass: 'sd-panel',
|
||||
disableClose: true
|
||||
});
|
||||
dialogRef.afterClosed().subscribe(() => {
|
||||
this.router.navigate(
|
||||
[
|
||||
'../../',
|
||||
targetId
|
||||
],
|
||||
{ relativeTo: this.route }
|
||||
);
|
||||
});
|
||||
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']
|
||||
this.dialog.open(ErrorDialogComponent, {
|
||||
data: err?.error?.defaultUserMessage || err?.error?.message || 'Failed to update user.'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
/* tslint:disable */
|
||||
export default {
|
||||
'mifos_x': {
|
||||
'version': '260508',
|
||||
'hash': '19c0e98e'
|
||||
'version': '260513',
|
||||
'hash': '6516d6c9'
|
||||
},
|
||||
'allow_switching_backend_instance': true
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user