merege code this branch /feature/UI-DSC-MFA

This commit is contained in:
Nitesh Yadav
2026-05-13 10:49:25 +05:30
28 changed files with 782 additions and 94 deletions
@@ -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: [
@@ -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') {
@@ -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;
@@ -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';
@@ -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'
@@ -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: [
@@ -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: [
+9
View File
@@ -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 }} &middot; CBSNexora Core Banking Platform</p>
</div>
+68
View File
@@ -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;
+29
View File
@@ -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);
}
+24 -17
View File
@@ -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.'
});
}
});
+2 -2
View File
@@ -10,8 +10,8 @@
/* tslint:disable */
export default {
'mifos_x': {
'version': '260508',
'hash': '19c0e98e'
'version': '260513',
'hash': '6516d6c9'
},
'allow_switching_backend_instance': true
};