diff --git a/src/app/access-control/access-control.component.html b/src/app/access-control/access-control.component.html index 191b3e53e..c65d59420 100644 --- a/src/app/access-control/access-control.component.html +++ b/src/app/access-control/access-control.component.html @@ -203,7 +203,7 @@ } @else { Manage devices arrow_forward } - Type · TOKEN + Type · DSC @@ -396,17 +396,28 @@ } @else { -
- check_circle +
+ {{ sms.mfaEnabled ? 'check_circle' : 'pause_circle' }}
- SMS OTP active - OTP will be sent to +91 {{ sms.destination }} on every sign-in. + {{ sms.mfaEnabled ? 'SMS OTP active' : 'SMS OTP verified · 2FA disabled' }} + + Number on file: +91 {{ sms.destination }} + @if (sms.mfaEnabled) { + — OTP will be sent on every sign-in. + } @else { + — re-enable any time without re-verifying. + } +
- + @if (sms.mfaEnabled) { + + } @else { + + }
}
@@ -566,17 +577,28 @@ } @else { -
- check_circle +
+ {{ email.mfaEnabled ? 'check_circle' : 'pause_circle' }}
- Email OTP active - OTP will be sent to {{ email.destination }} on every sign-in. + {{ email.mfaEnabled ? 'Email OTP active' : 'Email OTP verified · 2FA disabled' }} + + Address on file: {{ email.destination }} + @if (email.mfaEnabled) { + — OTP will be sent on every sign-in. + } @else { + — re-enable any time without re-verifying. + } +
- + @if (email.mfaEnabled) { + + } @else { + + }
}
@@ -697,7 +719,7 @@
memory

Hardware Token

- Type · TOKEN + Type · DSC
Map DSC + } @else if (currentHwItem.mfaEnabled) { + + } @else { } } @else { diff --git a/src/app/access-control/access-control.component.scss b/src/app/access-control/access-control.component.scss index d2abc00a2..2b9e1e9e6 100644 --- a/src/app/access-control/access-control.component.scss +++ b/src/app/access-control/access-control.component.scss @@ -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 { diff --git a/src/app/access-control/access-control.component.ts b/src/app/access-control/access-control.component.ts index 2964b098e..8c36fcb7a 100644 --- a/src/app/access-control/access-control.component.ts +++ b/src/app/access-control/access-control.component.ts @@ -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, diff --git a/src/app/access-control/access-control.service.ts b/src/app/access-control/access-control.service.ts index 2363964c8..10c73be32 100644 --- a/src/app/access-control/access-control.service.ts +++ b/src/app/access-control/access-control.service.ts @@ -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; } diff --git a/src/app/centers/centers-view/notes-tab/notes-tab.component.ts b/src/app/centers/centers-view/notes-tab/notes-tab.component.ts index f3b7c1934..f8f6646c9 100644 --- a/src/app/centers/centers-view/notes-tab/notes-tab.component.ts +++ b/src/app/centers/centers-view/notes-tab/notes-tab.component.ts @@ -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: [ diff --git a/src/app/clients/client-stepper/client-family-members-step/client-family-member-dialog/client-family-member-dialog.component.html b/src/app/clients/client-stepper/client-family-members-step/client-family-member-dialog/client-family-member-dialog.component.html index f21112a45..7e928ce87 100644 --- a/src/app/clients/client-stepper/client-family-members-step/client-family-member-dialog/client-family-member-dialog.component.html +++ b/src/app/clients/client-stepper/client-family-members-step/client-family-member-dialog/client-family-member-dialog.component.html @@ -298,9 +298,15 @@ Relationship @for (rel of data.options.relationshipIdOptions; track rel) { - {{ rel.name }} + + {{ rel.name }} + @if (disabledRelationshipIds.has(rel.id)) { + · already added + } + } + Father, Mother and Spouse can only be added once. @if (entryMode === 'manual') { diff --git a/src/app/clients/client-stepper/client-family-members-step/client-family-member-dialog/client-family-member-dialog.component.scss b/src/app/clients/client-stepper/client-family-members-step/client-family-member-dialog/client-family-member-dialog.component.scss index 90aa50ae5..4805baf5d 100644 --- a/src/app/clients/client-stepper/client-family-members-step/client-family-member-dialog/client-family-member-dialog.component.scss +++ b/src/app/clients/client-stepper/client-family-members-step/client-family-member-dialog/client-family-member-dialog.component.scss @@ -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; diff --git a/src/app/clients/client-stepper/client-family-members-step/client-family-member-dialog/client-family-member-dialog.component.ts b/src/app/clients/client-stepper/client-family-members-step/client-family-member-dialog/client-family-member-dialog.component.ts index 06dee441f..7412f6876 100644 --- a/src/app/clients/client-stepper/client-family-members-step/client-family-member-dialog/client-family-member-dialog.component.ts +++ b/src/app/clients/client-stepper/client-family-members-step/client-family-member-dialog/client-family-member-dialog.component.ts @@ -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 { + const disabled = new Set(); + 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'; diff --git a/src/app/clients/client-stepper/client-family-members-step/client-family-members-step.component.ts b/src/app/clients/client-stepper/client-family-members-step/client-family-members-step.component.ts index ec60d8867..ed40bd081 100644 --- a/src/app/clients/client-stepper/client-family-members-step/client-family-members-step.component.ts +++ b/src/app/clients/client-stepper/client-family-members-step/client-family-members-step.component.ts @@ -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' diff --git a/src/app/clients/clients-view/custom-dialogs/unassign-staff-dialog/unassign-staff-dialog.component.ts b/src/app/clients/clients-view/custom-dialogs/unassign-staff-dialog/unassign-staff-dialog.component.ts index ff7f33a25..d7e91894f 100644 --- a/src/app/clients/clients-view/custom-dialogs/unassign-staff-dialog/unassign-staff-dialog.component.ts +++ b/src/app/clients/clients-view/custom-dialogs/unassign-staff-dialog/unassign-staff-dialog.component.ts @@ -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: [ diff --git a/src/app/clients/clients-view/notes-tab/notes-tab.component.ts b/src/app/clients/clients-view/notes-tab/notes-tab.component.ts index 682d51bbd..de896b571 100644 --- a/src/app/clients/clients-view/notes-tab/notes-tab.component.ts +++ b/src/app/clients/clients-view/notes-tab/notes-tab.component.ts @@ -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: [ diff --git a/src/app/current-accounts/current-account-view/notes-tab/notes-tab.component.ts b/src/app/current-accounts/current-account-view/notes-tab/notes-tab.component.ts index 1493f27d2..a8b52dc80 100644 --- a/src/app/current-accounts/current-account-view/notes-tab/notes-tab.component.ts +++ b/src/app/current-accounts/current-account-view/notes-tab/notes-tab.component.ts @@ -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: [ diff --git a/src/app/groups/groups-view/custom-dialogs/unassign-staff-dialog/unassign-staff-dialog.component.ts b/src/app/groups/groups-view/custom-dialogs/unassign-staff-dialog/unassign-staff-dialog.component.ts index ff7f33a25..e3f4ac200 100644 --- a/src/app/groups/groups-view/custom-dialogs/unassign-staff-dialog/unassign-staff-dialog.component.ts +++ b/src/app/groups/groups-view/custom-dialogs/unassign-staff-dialog/unassign-staff-dialog.component.ts @@ -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: [ diff --git a/src/app/groups/groups-view/notes-tab/notes-tab.component.ts b/src/app/groups/groups-view/notes-tab/notes-tab.component.ts index f46867649..8965842c9 100644 --- a/src/app/groups/groups-view/notes-tab/notes-tab.component.ts +++ b/src/app/groups/groups-view/notes-tab/notes-tab.component.ts @@ -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: [ diff --git a/src/app/home/home.component.html b/src/app/home/home.component.html index 37a33e9a2..787b209a1 100644 --- a/src/app/home/home.component.html +++ b/src/app/home/home.component.html @@ -13,6 +13,15 @@

{{ 'labels.text.Welcome' | translate }}, {{ username }} + @if (dscMapped) { + + + + + + DSC Verified + + }

{{ tenant | titlecase }} · CBSNexora Core Banking Platform

diff --git a/src/app/home/home.component.scss b/src/app/home/home.component.scss index c736676ea..c2048c15f 100644 --- a/src/app/home/home.component.scss +++ b/src/app/home/home.component.scss @@ -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; diff --git a/src/app/home/home.component.ts b/src/app/home/home.component.ts index d2438cf4f..66e68ba47 100644 --- a/src/app/home/home.component.ts +++ b/src/app/home/home.component.ts @@ -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. */ diff --git a/src/app/loans/loans-view/notes-tab/notes-tab.component.ts b/src/app/loans/loans-view/notes-tab/notes-tab.component.ts index 0905473d5..09ac10f57 100644 --- a/src/app/loans/loans-view/notes-tab/notes-tab.component.ts +++ b/src/app/loans/loans-view/notes-tab/notes-tab.component.ts @@ -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: [ diff --git a/src/app/login/two-factor-authentication/two-factor-authentication.component.ts b/src/app/login/two-factor-authentication/two-factor-authentication.component.ts index 768cda2cd..c8d6dface 100644 --- a/src/app/login/two-factor-authentication/two-factor-authentication.component.ts +++ b/src/app/login/two-factor-authentication/two-factor-authentication.component.ts @@ -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({ diff --git a/src/app/savings/savings-account-view/notes-tab/notes-tab.component.ts b/src/app/savings/savings-account-view/notes-tab/notes-tab.component.ts index e419b900b..3b3510bd8 100644 --- a/src/app/savings/savings-account-view/notes-tab/notes-tab.component.ts +++ b/src/app/savings/savings-account-view/notes-tab/notes-tab.component.ts @@ -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: [ diff --git a/src/app/shared/error-dialog/error-dialog.component.html b/src/app/shared/error-dialog/error-dialog.component.html index e23d13a0d..892421df5 100644 --- a/src/app/shared/error-dialog/error-dialog.component.html +++ b/src/app/shared/error-dialog/error-dialog.component.html @@ -6,17 +6,19 @@ file, You can obtain one at http://mozilla.org/MPL/2.0/. --> -

{{ 'Error Log' | translate }}

- -
+ +
+ +
+

We encountered an issue. Please try again.

@if (!showAsCode) { -

{{ displayData }}

+

{{ displayData }}

} @if (showAsCode) { - +
} -
+ - - + + diff --git a/src/app/shared/error-dialog/error-dialog.component.scss b/src/app/shared/error-dialog/error-dialog.component.scss index af9f4c075..6a59c437a 100644 --- a/src/app/shared/error-dialog/error-dialog.component.scss +++ b/src/app/shared/error-dialog/error-dialog.component.scss @@ -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; + } +} diff --git a/src/app/shared/error-dialog/error-dialog.component.ts b/src/app/shared/error-dialog/error-dialog.component.ts index e09675f03..fb0d96e49 100644 --- a/src/app/shared/error-dialog/error-dialog.component.ts +++ b/src/app/shared/error-dialog/error-dialog.component.ts @@ -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 { diff --git a/src/app/shared/success-dialog/success-dialog.component.html b/src/app/shared/success-dialog/success-dialog.component.html new file mode 100644 index 000000000..db81a3b74 --- /dev/null +++ b/src/app/shared/success-dialog/success-dialog.component.html @@ -0,0 +1,23 @@ + + + +
+ +
+

{{ data.title || 'Success' }}

+ @if (data.message) { +

{{ data.message }}

+ } +
+ + + + diff --git a/src/app/shared/success-dialog/success-dialog.component.scss b/src/app/shared/success-dialog/success-dialog.component.scss new file mode 100644 index 000000000..2e5c3c9ab --- /dev/null +++ b/src/app/shared/success-dialog/success-dialog.component.scss @@ -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; + } +} diff --git a/src/app/shared/success-dialog/success-dialog.component.ts b/src/app/shared/success-dialog/success-dialog.component.ts new file mode 100644 index 000000000..eca68acfc --- /dev/null +++ b/src/app/shared/success-dialog/success-dialog.component.ts @@ -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); + data = inject(MAT_DIALOG_DATA); +} diff --git a/src/app/users/edit-user/edit-user.component.ts b/src/app/users/edit-user/edit-user.component.ts index f26465e06..30908290a 100644 --- a/src/app/users/edit-user/edit-user.component.ts +++ b/src/app/users/edit-user/edit-user.component.ts @@ -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.' }); } }); diff --git a/src/environments/.env.ts b/src/environments/.env.ts index af03575fb..02ca12c08 100644 --- a/src/environments/.env.ts +++ b/src/environments/.env.ts @@ -10,8 +10,8 @@ /* tslint:disable */ export default { 'mifos_x': { - 'version': '260508', - 'hash': '19c0e98e' + 'version': '260513', + 'hash': '6516d6c9' }, 'allow_switching_backend_instance': true };