merege code this branch /feature/UI-DSC-MFA
This commit is contained in:
@@ -203,7 +203,7 @@
|
|||||||
} @else {
|
} @else {
|
||||||
<span class="ac-tile__cta">Manage devices <mat-icon>arrow_forward</mat-icon></span>
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -396,17 +396,28 @@
|
|||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
} @else {
|
} @else {
|
||||||
<div class="ac-otp-done">
|
<div class="ac-otp-done" [class.is-disabled]="!sms.mfaEnabled">
|
||||||
<mat-icon>check_circle</mat-icon>
|
<mat-icon>{{ sms.mfaEnabled ? 'check_circle' : 'pause_circle' }}</mat-icon>
|
||||||
<div>
|
<div>
|
||||||
<strong>SMS OTP active</strong>
|
<strong>{{ sms.mfaEnabled ? 'SMS OTP active' : 'SMS OTP verified · 2FA disabled' }}</strong>
|
||||||
<span
|
<span>
|
||||||
>OTP will be sent to <code>+91 {{ sms.destination }}</code> on every sign-in.</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>
|
</div>
|
||||||
<button mat-stroked-button color="warn" (click)="disableOtpChannel(sms)">
|
@if (sms.mfaEnabled) {
|
||||||
<mat-icon>power_settings_new</mat-icon> Disable
|
<button mat-stroked-button color="warn" [disabled]="sms.busy" (click)="disableOtpChannel(sms)">
|
||||||
</button>
|
<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>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -566,17 +577,28 @@
|
|||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
} @else {
|
} @else {
|
||||||
<div class="ac-otp-done">
|
<div class="ac-otp-done" [class.is-disabled]="!email.mfaEnabled">
|
||||||
<mat-icon>check_circle</mat-icon>
|
<mat-icon>{{ email.mfaEnabled ? 'check_circle' : 'pause_circle' }}</mat-icon>
|
||||||
<div>
|
<div>
|
||||||
<strong>Email OTP active</strong>
|
<strong>{{ email.mfaEnabled ? 'Email OTP active' : 'Email OTP verified · 2FA disabled' }}</strong>
|
||||||
<span
|
<span>
|
||||||
>OTP will be sent to <code>{{ email.destination }}</code> on every sign-in.</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>
|
</div>
|
||||||
<button mat-stroked-button color="warn" (click)="disableOtpChannel(email)">
|
@if (email.mfaEnabled) {
|
||||||
<mat-icon>power_settings_new</mat-icon> Disable
|
<button mat-stroked-button color="warn" [disabled]="email.busy" (click)="disableOtpChannel(email)">
|
||||||
</button>
|
<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>
|
||||||
}
|
}
|
||||||
</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__icon ac-tile__icon--amber"><mat-icon>memory</mat-icon></div>
|
||||||
<div class="ac-panel__title">
|
<div class="ac-panel__title">
|
||||||
<h3>Hardware Token</h3>
|
<h3>Hardware Token</h3>
|
||||||
<span class="ac-type-pill">Type · TOKEN</span>
|
<span class="ac-type-pill">Type · DSC</span>
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span
|
||||||
class="ac-state-pill"
|
class="ac-state-pill"
|
||||||
@@ -828,11 +850,30 @@
|
|||||||
<fa-icon icon="file-signature" class="ac-hw-btn-icon"></fa-icon>
|
<fa-icon icon="file-signature" class="ac-hw-btn-icon"></fa-icon>
|
||||||
Map DSC
|
Map DSC
|
||||||
</button>
|
</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 {
|
} @else {
|
||||||
<button
|
<button
|
||||||
mat-flat-button
|
mat-flat-button
|
||||||
color="primary"
|
color="primary"
|
||||||
[disabled]="currentHwItem.mfaEnabled || currentHwItem.busy"
|
[disabled]="currentHwItem.busy"
|
||||||
(click)="enableDscMfa(currentHwItem)"
|
(click)="enableDscMfa(currentHwItem)"
|
||||||
>
|
>
|
||||||
<fa-icon icon="check-circle" class="ac-hw-btn-icon"></fa-icon>
|
<fa-icon icon="check-circle" class="ac-hw-btn-icon"></fa-icon>
|
||||||
@@ -840,12 +881,12 @@
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
mat-stroked-button
|
mat-stroked-button
|
||||||
color="warn"
|
class="ac-hw-revoke-btn"
|
||||||
[disabled]="!currentHwItem.mfaEnabled || currentHwItem.busy"
|
[disabled]="currentHwItem.busy"
|
||||||
(click)="disableDscMfa(currentHwItem)"
|
(click)="revokeDsc(currentHwItem)"
|
||||||
>
|
>
|
||||||
<fa-icon icon="ban" class="ac-hw-btn-icon"></fa-icon>
|
<fa-icon icon="trash" class="ac-hw-btn-icon"></fa-icon>
|
||||||
Disable for 2FA
|
Revoke DSC
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
} @else {
|
} @else {
|
||||||
|
|||||||
@@ -959,6 +959,21 @@
|
|||||||
font-size: 11.5px;
|
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 {
|
.ac-ff {
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ import { UsersService } from 'app/users/users.service';
|
|||||||
import { AccessControlService, MfaFactor } from './access-control.service';
|
import { AccessControlService, MfaFactor } from './access-control.service';
|
||||||
import { DscService } from 'app/users/dsc/dsc.service';
|
import { DscService } from 'app/users/dsc/dsc.service';
|
||||||
import { EnrollDscDialogComponent } from 'app/users/dsc/enroll-dsc-dialog/enroll-dsc-dialog.component';
|
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 HwSubKind = 'smart-card' | 'digital-signature' | 'biometric';
|
||||||
type MethodKey = 'sms' | 'email' | 'totp' | 'hardware';
|
type MethodKey = 'sms' | 'email' | 'totp' | 'hardware';
|
||||||
@@ -35,14 +38,26 @@ interface OtpChannelState {
|
|||||||
otp: string;
|
otp: string;
|
||||||
otpSent: boolean;
|
otpSent: boolean;
|
||||||
resendIn: number;
|
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;
|
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;
|
errorMsg: string;
|
||||||
busy: boolean;
|
busy: boolean;
|
||||||
/** otpId returned from the OTP-generate endpoint, used by verify */
|
/** otpId returned from the OTP-generate endpoint, used by verify */
|
||||||
otpId: number | null;
|
otpId: number | null;
|
||||||
/** verifiedOn timestamp from the server (ISO string) */
|
/** verifiedOn timestamp from the server (ISO string) */
|
||||||
verifiedOn: 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;
|
factorId: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,6 +82,10 @@ interface HardwareItem {
|
|||||||
busy: boolean;
|
busy: boolean;
|
||||||
/** DSC-only: whether the mapped DSC is currently enabled as a 2FA factor. */
|
/** DSC-only: whether the mapped DSC is currently enabled as a 2FA factor. */
|
||||||
mfaEnabled?: boolean;
|
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 {
|
interface PersistedPrefs {
|
||||||
@@ -294,12 +313,22 @@ export class AccessControlComponent implements OnInit, OnDestroy {
|
|||||||
} else if (f.factorType === 'TOTP' && f.verified) {
|
} else if (f.factorType === 'TOTP' && f.verified) {
|
||||||
this.totp.secret = f.secret || this.totp.secret;
|
this.totp.secret = f.secret || this.totp.secret;
|
||||||
this.totp.verified = true;
|
this.totp.verified = true;
|
||||||
} else if (f.factorType === 'TOKEN') {
|
} else if (f.factorType === 'DSC') {
|
||||||
const item = this.hardware.find((h) => h.serial === f.serial);
|
// 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) {
|
if (item) {
|
||||||
item.registered = true;
|
item.registered = true;
|
||||||
item.serial = f.serial || item.serial;
|
item.serial = f.serial || item.serial;
|
||||||
item.registeredAt = f.verifiedOn || item.registeredAt;
|
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. */
|
/** Wipe every factor's verified / registered flags before re-applying API truth. */
|
||||||
private resetAllFactors(): void {
|
private resetAllFactors(): void {
|
||||||
this.sms.verified = false;
|
this.sms.verified = false;
|
||||||
|
this.sms.mfaEnabled = false;
|
||||||
this.sms.destinationConfirmed = false;
|
this.sms.destinationConfirmed = false;
|
||||||
this.sms.factorId = null;
|
this.sms.factorId = null;
|
||||||
this.sms.verifiedOn = '';
|
this.sms.verifiedOn = '';
|
||||||
this.email.verified = false;
|
this.email.verified = false;
|
||||||
|
this.email.mfaEnabled = false;
|
||||||
this.email.destinationConfirmed = false;
|
this.email.destinationConfirmed = false;
|
||||||
this.email.factorId = null;
|
this.email.factorId = null;
|
||||||
this.email.verifiedOn = '';
|
this.email.verifiedOn = '';
|
||||||
@@ -332,14 +363,22 @@ export class AccessControlComponent implements OnInit, OnDestroy {
|
|||||||
h.registered = false;
|
h.registered = false;
|
||||||
h.serial = '';
|
h.serial = '';
|
||||||
h.registeredAt = '';
|
h.registeredAt = '';
|
||||||
|
h.mfaEnabled = false;
|
||||||
|
h.factorId = null;
|
||||||
|
h.dscId = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private applyFactor(channel: OtpChannelState, f: MfaFactor): void {
|
private applyFactor(channel: OtpChannelState, f: MfaFactor): void {
|
||||||
if (f.destination) channel.destination = f.destination;
|
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.verified = !!f.verified;
|
||||||
channel.destinationConfirmed = !!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 || '';
|
channel.verifiedOn = f.verifiedOn || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -434,6 +473,10 @@ export class AccessControlComponent implements OnInit, OnDestroy {
|
|||||||
const finishVerified = (saved?: MfaFactor) => {
|
const finishVerified = (saved?: MfaFactor) => {
|
||||||
channel.busy = false;
|
channel.busy = false;
|
||||||
channel.verified = true;
|
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.otpSent = false;
|
||||||
channel.resendIn = 0;
|
channel.resendIn = 0;
|
||||||
channel.factorId = saved?.id ?? null;
|
channel.factorId = saved?.id ?? null;
|
||||||
@@ -473,6 +516,41 @@ export class AccessControlComponent implements OnInit, OnDestroy {
|
|||||||
channel.otpId = null;
|
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 {
|
disableOtpChannel(channel: OtpChannelState): void {
|
||||||
const label = channel.typeCode === 'SMS' ? 'SMS OTP' : 'Email OTP';
|
const label = channel.typeCode === 'SMS' ? 'SMS OTP' : 'Email OTP';
|
||||||
const destination =
|
const destination =
|
||||||
@@ -490,19 +568,17 @@ export class AccessControlComponent implements OnInit, OnDestroy {
|
|||||||
.afterClosed()
|
.afterClosed()
|
||||||
.subscribe((response: any) => {
|
.subscribe((response: any) => {
|
||||||
if (!response?.confirmed) return;
|
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 = () => {
|
const finish = () => {
|
||||||
channel.verified = false;
|
channel.mfaEnabled = false;
|
||||||
channel.otp = '';
|
|
||||||
channel.otpSent = false;
|
|
||||||
channel.destinationConfirmed = false;
|
|
||||||
channel.errorMsg = '';
|
|
||||||
channel.resendIn = 0;
|
|
||||||
channel.otpId = null;
|
|
||||||
channel.factorId = null;
|
channel.factorId = null;
|
||||||
channel.verifiedOn = '';
|
channel.busy = false;
|
||||||
this.savePrefs();
|
this.savePrefs();
|
||||||
};
|
};
|
||||||
if (this.mfaFactorsApiAvailable && channel.factorId != null) {
|
if (this.mfaFactorsApiAvailable && channel.factorId != null) {
|
||||||
|
channel.busy = true;
|
||||||
this.accessControlService.disableFactor(channel.factorId).subscribe({
|
this.accessControlService.disableFactor(channel.factorId).subscribe({
|
||||||
next: () => finish(),
|
next: () => finish(),
|
||||||
error: () => finish()
|
error: () => finish()
|
||||||
@@ -646,6 +722,7 @@ export class AccessControlComponent implements OnInit, OnDestroy {
|
|||||||
if (!result?.enrolled) return;
|
if (!result?.enrolled) return;
|
||||||
const dsc = result.dsc;
|
const dsc = result.dsc;
|
||||||
item.registered = true;
|
item.registered = true;
|
||||||
|
item.dscId = dsc?.id ?? null;
|
||||||
item.serial = dsc?.dscSerialNoFull || dsc?.dscSerialNoMasked || '';
|
item.serial = dsc?.dscSerialNoFull || dsc?.dscSerialNoMasked || '';
|
||||||
item.registeredAt = new Date().toLocaleString();
|
item.registeredAt = new Date().toLocaleString();
|
||||||
item.mfaEnabled = false;
|
item.mfaEnabled = false;
|
||||||
@@ -654,13 +731,137 @@ export class AccessControlComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
enableDscMfa(item: HardwareItem): void {
|
enableDscMfa(item: HardwareItem): void {
|
||||||
item.mfaEnabled = true;
|
if (!item.registered || !item.serial) {
|
||||||
this.savePrefs();
|
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 {
|
disableDscMfa(item: HardwareItem): void {
|
||||||
item.mfaEnabled = false;
|
this.dialog
|
||||||
this.savePrefs();
|
.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');
|
const active = (dscs || []).find((d) => (d?.certStatus || '').toUpperCase() === 'ACTIVE');
|
||||||
if (active) {
|
if (active) {
|
||||||
dscItem.registered = true;
|
dscItem.registered = true;
|
||||||
|
dscItem.dscId = active.id;
|
||||||
dscItem.serial = active.dscSerialNoMasked || active.dscSerialNoFull || dscItem.serial;
|
dscItem.serial = active.dscSerialNoMasked || active.dscSerialNoFull || dscItem.serial;
|
||||||
dscItem.registeredAt = dscItem.registeredAt || new Date().toLocaleString();
|
dscItem.registeredAt = dscItem.registeredAt || new Date().toLocaleString();
|
||||||
}
|
}
|
||||||
@@ -695,17 +897,31 @@ export class AccessControlComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
// ───────────────────────────── Aggregate state
|
// ───────────────────────────── 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 {
|
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 {
|
get activeMethod(): MethodKey | null {
|
||||||
if (this.sms.verified) return 'sms';
|
if (this.sms.verified && this.sms.mfaEnabled) return 'sms';
|
||||||
if (this.email.verified) return 'email';
|
if (this.email.verified && this.email.mfaEnabled) return 'email';
|
||||||
if (this.totp.verified) return 'totp';
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -803,6 +1019,7 @@ export class AccessControlComponent implements OnInit, OnDestroy {
|
|||||||
otpSent: false,
|
otpSent: false,
|
||||||
resendIn: 0,
|
resendIn: 0,
|
||||||
verified: false,
|
verified: false,
|
||||||
|
mfaEnabled: false,
|
||||||
errorMsg: '',
|
errorMsg: '',
|
||||||
busy: false,
|
busy: false,
|
||||||
otpId: null,
|
otpId: null,
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export interface OtpVerifyResponse {
|
|||||||
message?: string;
|
message?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type MfaFactorType = 'SMS' | 'EMAIL' | 'TOTP' | 'TOKEN';
|
export type MfaFactorType = 'SMS' | 'EMAIL' | 'TOTP' | 'TOKEN' | 'DSC';
|
||||||
|
|
||||||
export interface MfaFactor {
|
export interface MfaFactor {
|
||||||
id?: number;
|
id?: number;
|
||||||
@@ -32,6 +32,8 @@ export interface MfaFactor {
|
|||||||
serial?: string;
|
serial?: string;
|
||||||
verified: boolean;
|
verified: boolean;
|
||||||
verifiedOn?: string;
|
verifiedOn?: string;
|
||||||
|
/** ISO timestamp when the factor was logically disabled — when set, the factor is NOT active. */
|
||||||
|
disabledOn?: string;
|
||||||
otpLogId?: number;
|
otpLogId?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { EntityNotesTabComponent } from '../../../shared/tabs/entity-notes-tab/e
|
|||||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'mifosx-notes-tab',
|
selector: 'mifosx-centers-notes-tab',
|
||||||
templateUrl: './notes-tab.component.html',
|
templateUrl: './notes-tab.component.html',
|
||||||
styleUrls: ['./notes-tab.component.scss'],
|
styleUrls: ['./notes-tab.component.scss'],
|
||||||
imports: [
|
imports: [
|
||||||
|
|||||||
+7
-1
@@ -298,9 +298,15 @@
|
|||||||
<mat-label>Relationship</mat-label>
|
<mat-label>Relationship</mat-label>
|
||||||
<mat-select required formControlName="relationshipId">
|
<mat-select required formControlName="relationshipId">
|
||||||
@for (rel of data.options.relationshipIdOptions; track rel) {
|
@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-select>
|
||||||
|
<mat-hint>Father, Mother and Spouse can only be added once.</mat-hint>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
|
|
||||||
@if (entryMode === 'manual') {
|
@if (entryMode === 'manual') {
|
||||||
|
|||||||
+8
@@ -28,6 +28,14 @@
|
|||||||
margin-bottom: 16px;
|
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 {
|
.fm-mode-btn {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
+36
@@ -86,6 +86,42 @@ export class ClientFamilyMemberDialogComponent implements OnInit {
|
|||||||
return rel?.name?.toUpperCase() === 'SPOUSE';
|
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 */
|
/** Whether dialog is in View-only mode */
|
||||||
get isViewMode(): boolean {
|
get isViewMode(): boolean {
|
||||||
return this.data.context === 'View';
|
return this.data.context === 'View';
|
||||||
|
|||||||
+17
-3
@@ -132,7 +132,11 @@ export class ClientFamilyMembersStepComponent implements OnInit {
|
|||||||
|
|
||||||
addFamilyMember() {
|
addFamilyMember() {
|
||||||
const ref = this.dialog.open(ClientFamilyMemberDialogComponent, {
|
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',
|
width: '640px',
|
||||||
maxWidth: '95vw',
|
maxWidth: '95vw',
|
||||||
panelClass: 'fm-dialog-panel'
|
panelClass: 'fm-dialog-panel'
|
||||||
@@ -146,7 +150,12 @@ export class ClientFamilyMembersStepComponent implements OnInit {
|
|||||||
|
|
||||||
viewFamilyMember(member: any) {
|
viewFamilyMember(member: any) {
|
||||||
this.dialog.open(ClientFamilyMemberDialogComponent, {
|
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',
|
width: '640px',
|
||||||
maxWidth: '95vw',
|
maxWidth: '95vw',
|
||||||
panelClass: 'fm-dialog-panel'
|
panelClass: 'fm-dialog-panel'
|
||||||
@@ -155,7 +164,12 @@ export class ClientFamilyMembersStepComponent implements OnInit {
|
|||||||
|
|
||||||
editFamilyMember(member: any, index: number) {
|
editFamilyMember(member: any, index: number) {
|
||||||
const ref = this.dialog.open(ClientFamilyMemberDialogComponent, {
|
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',
|
width: '640px',
|
||||||
maxWidth: '95vw',
|
maxWidth: '95vw',
|
||||||
panelClass: 'fm-dialog-panel'
|
panelClass: 'fm-dialog-panel'
|
||||||
|
|||||||
+1
-1
@@ -23,7 +23,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
|||||||
* Unassign staff dialog component.
|
* Unassign staff dialog component.
|
||||||
*/
|
*/
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'mifosx-unassign-staff-dialog',
|
selector: 'mifosx-clients-unassign-staff-dialog',
|
||||||
templateUrl: './unassign-staff-dialog.component.html',
|
templateUrl: './unassign-staff-dialog.component.html',
|
||||||
styleUrls: ['./unassign-staff-dialog.component.scss'],
|
styleUrls: ['./unassign-staff-dialog.component.scss'],
|
||||||
imports: [
|
imports: [
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
|||||||
* Notes Tab Component
|
* Notes Tab Component
|
||||||
*/
|
*/
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'mifosx-notes-tab',
|
selector: 'mifosx-clients-notes-tab',
|
||||||
templateUrl: './notes-tab.component.html',
|
templateUrl: './notes-tab.component.html',
|
||||||
styleUrls: ['./notes-tab.component.scss'],
|
styleUrls: ['./notes-tab.component.scss'],
|
||||||
imports: [
|
imports: [
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { EntityNotesTabComponent } from '../../../shared/tabs/entity-notes-tab/e
|
|||||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'mifosx-notes-tab',
|
selector: 'mifosx-current-account-notes-tab',
|
||||||
templateUrl: './notes-tab.component.html',
|
templateUrl: './notes-tab.component.html',
|
||||||
styleUrls: ['./notes-tab.component.scss'],
|
styleUrls: ['./notes-tab.component.scss'],
|
||||||
imports: [
|
imports: [
|
||||||
|
|||||||
+1
-1
@@ -23,7 +23,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
|||||||
* Unassign staff dialog component.
|
* Unassign staff dialog component.
|
||||||
*/
|
*/
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'mifosx-unassign-staff-dialog',
|
selector: 'mifosx-groups-unassign-staff-dialog',
|
||||||
templateUrl: './unassign-staff-dialog.component.html',
|
templateUrl: './unassign-staff-dialog.component.html',
|
||||||
styleUrls: ['./unassign-staff-dialog.component.scss'],
|
styleUrls: ['./unassign-staff-dialog.component.scss'],
|
||||||
imports: [
|
imports: [
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
|||||||
* Groups Notes Tab Component.
|
* Groups Notes Tab Component.
|
||||||
*/
|
*/
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'mifosx-notes-tab',
|
selector: 'mifosx-groups-notes-tab',
|
||||||
templateUrl: './notes-tab.component.html',
|
templateUrl: './notes-tab.component.html',
|
||||||
styleUrls: ['./notes-tab.component.scss'],
|
styleUrls: ['./notes-tab.component.scss'],
|
||||||
imports: [
|
imports: [
|
||||||
|
|||||||
@@ -13,6 +13,15 @@
|
|||||||
<div class="welcome-greeting">
|
<div class="welcome-greeting">
|
||||||
<h2>
|
<h2>
|
||||||
{{ 'labels.text.Welcome' | translate }}, <strong>{{ username }}</strong>
|
{{ '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>
|
</h2>
|
||||||
<p class="welcome-sub">{{ tenant | titlecase }} · CBSNexora Core Banking Platform</p>
|
<p class="welcome-sub">{{ tenant | titlecase }} · CBSNexora Core Banking Platform</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -54,6 +54,10 @@ $accent: #0ea5e9;
|
|||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
color: rgb(255 255 255 / 90%);
|
color: rgb(255 255 255 / 90%);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
|
||||||
strong {
|
strong {
|
||||||
font-weight: 700;
|
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 {
|
.welcome-sub {
|
||||||
margin: 4px 0 0;
|
margin: 4px 0 0;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
|||||||
@@ -25,11 +25,13 @@ import { AuthenticationService } from '../core/authentication/authentication.ser
|
|||||||
import { PopoverService } from '../configuration-wizard/popover/popover.service';
|
import { PopoverService } from '../configuration-wizard/popover/popover.service';
|
||||||
import { ConfigurationWizardService } from '../configuration-wizard/configuration-wizard.service';
|
import { ConfigurationWizardService } from '../configuration-wizard/configuration-wizard.service';
|
||||||
import { SettingsService } from 'app/settings/settings.service';
|
import { SettingsService } from 'app/settings/settings.service';
|
||||||
|
import { DscService } from 'app/users/dsc/dsc.service';
|
||||||
|
|
||||||
/** Custom Components */
|
/** Custom Components */
|
||||||
import { NextStepDialogComponent } from '../configuration-wizard/next-step-dialog/next-step-dialog.component';
|
import { NextStepDialogComponent } from '../configuration-wizard/next-step-dialog/next-step-dialog.component';
|
||||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||||
import { MatAutocompleteTrigger, MatAutocomplete, MatOption } from '@angular/material/autocomplete';
|
import { MatAutocompleteTrigger, MatAutocomplete, MatOption } from '@angular/material/autocomplete';
|
||||||
|
import { MatTooltip } from '@angular/material/tooltip';
|
||||||
import { AsyncPipe, DatePipe, TitleCasePipe } from '@angular/common';
|
import { AsyncPipe, DatePipe, TitleCasePipe } from '@angular/common';
|
||||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||||
|
|
||||||
@@ -45,6 +47,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
|||||||
FaIconComponent,
|
FaIconComponent,
|
||||||
MatAutocompleteTrigger,
|
MatAutocompleteTrigger,
|
||||||
MatAutocomplete,
|
MatAutocomplete,
|
||||||
|
MatTooltip,
|
||||||
AsyncPipe,
|
AsyncPipe,
|
||||||
DatePipe,
|
DatePipe,
|
||||||
TitleCasePipe
|
TitleCasePipe
|
||||||
@@ -58,6 +61,7 @@ export class HomeComponent implements OnInit, AfterViewInit {
|
|||||||
private configurationWizardService = inject(ConfigurationWizardService);
|
private configurationWizardService = inject(ConfigurationWizardService);
|
||||||
private popoverService = inject(PopoverService);
|
private popoverService = inject(PopoverService);
|
||||||
private settingsService = inject(SettingsService);
|
private settingsService = inject(SettingsService);
|
||||||
|
private dscService = inject(DscService);
|
||||||
|
|
||||||
/** Username of authenticated user. */
|
/** Username of authenticated user. */
|
||||||
username: string;
|
username: string;
|
||||||
@@ -65,6 +69,10 @@ export class HomeComponent implements OnInit, AfterViewInit {
|
|||||||
tenant: string;
|
tenant: string;
|
||||||
/** Today's date */
|
/** Today's date */
|
||||||
today = new 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. */
|
/** Activity Form. */
|
||||||
activityForm: any;
|
activityForm: any;
|
||||||
/** Search Text. */
|
/** Search Text. */
|
||||||
@@ -95,6 +103,7 @@ export class HomeComponent implements OnInit, AfterViewInit {
|
|||||||
this.username = credentials.username;
|
this.username = credentials.username;
|
||||||
this.tenant = this.tenantIdentifier();
|
this.tenant = this.tenantIdentifier();
|
||||||
this.setFilteredActivities();
|
this.setFilteredActivities();
|
||||||
|
this.loadDscStatus();
|
||||||
if (!this.authenticationService.hasDialogBeenShown()) {
|
if (!this.authenticationService.hasDialogBeenShown()) {
|
||||||
this.dialog.open(WarningDialogComponent, {
|
this.dialog.open(WarningDialogComponent, {
|
||||||
disableClose: false,
|
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.
|
* 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';
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'mifosx-notes-tab',
|
selector: 'mifosx-loans-notes-tab',
|
||||||
templateUrl: './notes-tab.component.html',
|
templateUrl: './notes-tab.component.html',
|
||||||
styleUrls: ['./notes-tab.component.scss'],
|
styleUrls: ['./notes-tab.component.scss'],
|
||||||
imports: [
|
imports: [
|
||||||
|
|||||||
@@ -51,8 +51,18 @@ export class TwoFactorAuthenticationComponent implements OnInit, OnDestroy {
|
|||||||
otpRequested = false;
|
otpRequested = false;
|
||||||
/** Time for which OTP is valid. */
|
/** Time for which OTP is valid. */
|
||||||
tokenValidityTime: number;
|
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. */
|
/** Two factor authentication form group. */
|
||||||
twoFactorAuthenticationForm: UntypedFormGroup;
|
twoFactorAuthenticationForm: UntypedFormGroup;
|
||||||
/** True if loading. */
|
/** True if loading. */
|
||||||
@@ -81,7 +91,7 @@ export class TwoFactorAuthenticationComponent implements OnInit, OnDestroy {
|
|||||||
* Gets the delivery methods available to receive OTP.
|
* Gets the delivery methods available to receive OTP.
|
||||||
*/
|
*/
|
||||||
ngOnInit() {
|
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,
|
// 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.
|
// merge them into a single factor list, then auto-route if there's only one.
|
||||||
this.authenticationService.getDeliveryMethods().subscribe({
|
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';
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'mifosx-notes-tab',
|
selector: 'mifosx-savings-notes-tab',
|
||||||
templateUrl: './notes-tab.component.html',
|
templateUrl: './notes-tab.component.html',
|
||||||
styleUrls: ['./notes-tab.component.scss'],
|
styleUrls: ['./notes-tab.component.scss'],
|
||||||
imports: [
|
imports: [
|
||||||
|
|||||||
@@ -6,17 +6,19 @@
|
|||||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
-->
|
-->
|
||||||
|
|
||||||
<h1 mat-dialog-title>{{ 'Error Log' | translate }}</h1>
|
<mat-dialog-content class="ed-content">
|
||||||
|
<div class="ed-icon">
|
||||||
<div mat-dialog-content>
|
<fa-icon icon="exclamation-circle"></fa-icon>
|
||||||
|
</div>
|
||||||
|
<h2 class="ed-title">We encountered an issue. Please try again.</h2>
|
||||||
@if (!showAsCode) {
|
@if (!showAsCode) {
|
||||||
<p>{{ displayData }}</p>
|
<p class="ed-message">{{ displayData }}</p>
|
||||||
}
|
}
|
||||||
@if (showAsCode) {
|
@if (showAsCode) {
|
||||||
<span [innerHTML]="sanitizedData"></span>
|
<div class="ed-code" [innerHTML]="sanitizedData"></div>
|
||||||
}
|
}
|
||||||
</div>
|
</mat-dialog-content>
|
||||||
|
|
||||||
<mat-dialog-actions align="left">
|
<mat-dialog-actions align="center" class="ed-actions">
|
||||||
<button mat-raised-button mat-dialog-close>{{ 'Cancel' | translate }}</button>
|
<button mat-flat-button color="warn" mat-dialog-close>OK</button>
|
||||||
</mat-dialog-actions>
|
</mat-dialog-actions>
|
||||||
|
|||||||
@@ -5,3 +5,87 @@
|
|||||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
* 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/.
|
* 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 {
|
import {
|
||||||
MatDialogRef,
|
MatDialogRef,
|
||||||
MAT_DIALOG_DATA,
|
MAT_DIALOG_DATA,
|
||||||
MatDialogTitle,
|
|
||||||
MatDialogContent,
|
MatDialogContent,
|
||||||
MatDialogActions,
|
MatDialogActions,
|
||||||
MatDialogClose
|
MatDialogClose
|
||||||
} from '@angular/material/dialog';
|
} from '@angular/material/dialog';
|
||||||
import { CdkScrollable } from '@angular/cdk/scrolling';
|
import { CdkScrollable } from '@angular/cdk/scrolling';
|
||||||
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
|
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
|
||||||
|
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -26,11 +26,11 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
|||||||
styleUrls: ['./error-dialog.component.scss'],
|
styleUrls: ['./error-dialog.component.scss'],
|
||||||
imports: [
|
imports: [
|
||||||
...STANDALONE_SHARED_IMPORTS,
|
...STANDALONE_SHARED_IMPORTS,
|
||||||
MatDialogTitle,
|
|
||||||
CdkScrollable,
|
CdkScrollable,
|
||||||
MatDialogContent,
|
MatDialogContent,
|
||||||
MatDialogActions,
|
MatDialogActions,
|
||||||
MatDialogClose
|
MatDialogClose,
|
||||||
|
FaIconComponent
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class ErrorDialogComponent {
|
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 { Component, OnInit, inject } from '@angular/core';
|
||||||
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||||
import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
|
import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
|
||||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
import { MatDialog } from '@angular/material/dialog';
|
||||||
|
|
||||||
/** Custom Services */
|
/** Custom Services */
|
||||||
import { UsersService } from '../users.service';
|
import { UsersService } from '../users.service';
|
||||||
import { MatCheckbox } from '@angular/material/checkbox';
|
import { MatCheckbox } from '@angular/material/checkbox';
|
||||||
import { MatIcon } from '@angular/material/icon';
|
import { MatIcon } from '@angular/material/icon';
|
||||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
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.
|
* Edit User Component.
|
||||||
@@ -28,8 +30,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
|||||||
imports: [
|
imports: [
|
||||||
...STANDALONE_SHARED_IMPORTS,
|
...STANDALONE_SHARED_IMPORTS,
|
||||||
MatCheckbox,
|
MatCheckbox,
|
||||||
MatIcon,
|
MatIcon
|
||||||
MatSnackBarModule
|
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class EditUserComponent implements OnInit {
|
export class EditUserComponent implements OnInit {
|
||||||
@@ -37,7 +38,7 @@ export class EditUserComponent implements OnInit {
|
|||||||
private usersService = inject(UsersService);
|
private usersService = inject(UsersService);
|
||||||
private route = inject(ActivatedRoute);
|
private route = inject(ActivatedRoute);
|
||||||
private router = inject(Router);
|
private router = inject(Router);
|
||||||
private snackBar = inject(MatSnackBar);
|
private dialog = inject(MatDialog);
|
||||||
|
|
||||||
/** User Data */
|
/** User Data */
|
||||||
userData: any;
|
userData: any;
|
||||||
@@ -144,23 +145,29 @@ export class EditUserComponent implements OnInit {
|
|||||||
// Fineract sometimes omits resourceId in PUT responses — fall back to
|
// Fineract sometimes omits resourceId in PUT responses — fall back to
|
||||||
// the user id we're editing so navigation never silently no-ops.
|
// the user id we're editing so navigation never silently no-ops.
|
||||||
const targetId = response?.resourceId ?? this.userData.id;
|
const targetId = response?.resourceId ?? this.userData.id;
|
||||||
this.snackBar.open('User updated successfully', 'OK', {
|
const dialogRef = this.dialog.open(SuccessDialogComponent, {
|
||||||
duration: 3000,
|
data: {
|
||||||
panelClass: ['success-snackbar']
|
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) => {
|
error: (err) => {
|
||||||
console.error('[EditUser] update failed', err);
|
console.error('[EditUser] update failed', err);
|
||||||
this.snackBar.open('Failed to update user', 'Close', {
|
this.dialog.open(ErrorDialogComponent, {
|
||||||
duration: 4000,
|
data: err?.error?.defaultUserMessage || err?.error?.message || 'Failed to update user.'
|
||||||
panelClass: ['error-snackbar']
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,8 +10,8 @@
|
|||||||
/* tslint:disable */
|
/* tslint:disable */
|
||||||
export default {
|
export default {
|
||||||
'mifos_x': {
|
'mifos_x': {
|
||||||
'version': '260508',
|
'version': '260513',
|
||||||
'hash': '19c0e98e'
|
'hash': '6516d6c9'
|
||||||
},
|
},
|
||||||
'allow_switching_backend_instance': true
|
'allow_switching_backend_instance': true
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user