diff --git a/src/app/clients/client-stepper/client-general-step/client-general-step.component.html b/src/app/clients/client-stepper/client-general-step/client-general-step.component.html index fdcb8c635..a75eb76b2 100644 --- a/src/app/clients/client-stepper/client-general-step/client-general-step.component.html +++ b/src/app/clients/client-stepper/client-general-step/client-general-step.component.html @@ -995,6 +995,32 @@ } + + + @if (!clientData) { +
+ @if (createClientForm.get('mobileNo')?.valid) { + + } + @if (createClientForm.get('emailAddress')?.valid) { + + } +
+ } diff --git a/src/app/clients/client-stepper/client-general-step/client-general-step.component.scss b/src/app/clients/client-stepper/client-general-step/client-general-step.component.scss index 9c749bd8a..e2caf5e84 100644 --- a/src/app/clients/client-stepper/client-general-step/client-general-step.component.scss +++ b/src/app/clients/client-stepper/client-general-step/client-general-step.component.scss @@ -1493,6 +1493,21 @@ form { } } +/* Create-time mobile/email OTP verification — spans both `.form-grid` columns so the two + * blocks sit on their own full-width row instead of disrupting the field/verify-button pairing + * above them. */ +.contact-otp-row { + display: flex; + flex-wrap: wrap; + gap: 16px; + grid-column: 1 / -1; + margin-top: 4px; + + mifosx-client-contact-otp-block { + flex: 1 1 260px; + } +} + @media (width <= 768px) { .form-grid { grid-template-columns: 1fr; @@ -1516,6 +1531,10 @@ form { max-width: none; } } + + .contact-otp-row { + flex-direction: column; + } } .guardian-card__avatar.has-photo { diff --git a/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts b/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts index 971979e4f..0ef4e187e 100644 --- a/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts +++ b/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts @@ -50,6 +50,10 @@ import { DateFormatPipe } from '../../../pipes/date-format.pipe'; import { SanctionsVerifyDialogComponent } from './sanctions-verify-dialog.component'; import { DipVerifyDialogComponent } from './dip-verify-dialog.component'; import { DinVerifyDialogComponent } from './din-verify-dialog.component'; +import { + ContactOtpBlockComponent, + ContactOtpState +} from 'app/clients/edit-client/contact-otp-block/contact-otp-block.component'; /** * Create Client Component @@ -68,7 +72,8 @@ import { DinVerifyDialogComponent } from './din-verify-dialog.component'; MatIcon, MatTooltip, MatSelectTrigger, - DateFormatPipe + DateFormatPipe, + ContactOtpBlockComponent ] }) export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges { @@ -183,6 +188,45 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges alternateDipLookupLogId: number | null = null; /** Alternate DIP lookup timestamp from API */ alternateDipTimestamp: string | null = null; + + /** + * CLIENT CREATION contact-OTP verification state (mobileNo / emailAddress). Unlike the + * edit-flow dual old/new OTP panel in `EditClientComponent`, a brand-new client has no "old" + * destination to verify against — so only a single "new value" OTP-verification is needed + * per field here. Sent to the backend as `mobileNewOtpId`/`emailNewOtpId` alongside the rest + * of the create-client payload (see `CreateClientComponent.saveAndProceed()`), which requires + * both whenever `mobileNo`/`emailAddress` are present (and they always are — both are + * `Validators.required` on this form). + */ + mobileOtpVerified = false; + mobileNewOtpId: number | null = null; + emailOtpVerified = false; + emailNewOtpId: number | null = null; + + /** + * Whether both mobile and email have been OTP-verified for CLIENT CREATION. Always `true` + * in edit mode (`clientData` set) — edit-time contact verification is gated separately by + * `EditClientComponent.contactVerificationComplete`, not by this component. + */ + get contactVerified(): boolean { + if (this.clientData) { + return true; + } + return this.mobileOtpVerified && this.emailOtpVerified; + } + + /** Resets create-time OTP verification for mobileNo — called whenever the field's value + * changes after a prior verification, since that OTP no longer applies to the new value. */ + onMobileOtpState(state: ContactOtpState): void { + this.mobileOtpVerified = state.verified; + this.mobileNewOtpId = state.otpId; + } + + /** Resets create-time OTP verification for emailAddress — see `onMobileOtpState()`. */ + onEmailOtpState(state: ContactOtpState): void { + this.emailOtpVerified = state.verified; + this.emailNewOtpId = state.otpId; + } /** Constitution Types Master with KYC field metadata */ constitutionTypesMaster = [ { @@ -853,6 +897,13 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges .valueChanges.pipe(takeUntil(this.destroy$)) .subscribe(() => { this.mobileVerified = false; + // Also reset create-time contact-OTP verification — an OTP verified against a + // previous mobile number no longer applies once the value is edited. Belt-and-braces + // alongside ContactOtpBlockComponent's own destination-change reset: this fires even + // if the OTP block was unmounted (e.g. `*ngIf` hid it while the field was momentarily + // invalid) and its own reset never had a chance to run. + this.mobileOtpVerified = false; + this.mobileNewOtpId = null; }); this.createClientForm @@ -861,6 +912,16 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges .subscribe(() => { this.alternateMobileVerified = false; }); + + // Reset create-time contact-OTP verification when email address changes — see the + // mobileNo subscription above for the rationale. + this.createClientForm + .get('emailAddress') + .valueChanges.pipe(takeUntil(this.destroy$)) + .subscribe(() => { + this.emailOtpVerified = false; + this.emailNewOtpId = null; + }); } ngOnChanges(changes: SimpleChanges) { diff --git a/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.html b/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.html index 354581963..64943f42c 100644 --- a/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.html +++ b/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.html @@ -1359,10 +1359,15 @@ * JPG / PNG, max 2MB - +
+ + +
@if (photoSigTouched) { Photograph is required } @@ -1393,10 +1398,15 @@ * Scanned image, max 1MB - +
+ + +
@if (photoSigTouched) { Signature is required } diff --git a/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.scss b/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.scss index 6d717cc62..2b53e5ecc 100644 --- a/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.scss +++ b/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.scss @@ -1057,9 +1057,17 @@ color: #94a3b8; } + &__btn-row { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 8px; + } + &__btn { display: inline-flex; align-items: center; + gap: 6px; padding: 6px 16px; background: #fff; border: 1px solid #d0d5dd; @@ -1074,6 +1082,10 @@ border-color: #1353a0; color: #1353a0; } + + &--secondary { + background: #f8fafc; + } } &__status { diff --git a/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.ts b/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.ts index ed0e231e5..97f850e9d 100644 --- a/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.ts +++ b/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.ts @@ -30,6 +30,8 @@ import { SettingsService } from 'app/settings/settings.service'; import { Dates } from 'app/core/utils/dates'; import { ProofType, normalizeProofType, groupProofsByCategory } from '../../shared/kyc-proof-types'; import { KycUploadHelperService } from '../../shared/kyc-upload-helper.service'; +import { CaptureImageDialogComponent } from '../../clients-view/custom-dialogs/capture-image-dialog/capture-image-dialog.component'; +import { dataUrlToFile } from 'app/shared/utils/data-url.util'; @Component({ selector: 'mifosx-client-kyc-step', @@ -128,6 +130,31 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy { markPhotoSigTouched(): void { this.photoSigTouched = true; } + + /** Human-readable label for each `kycForm` control that can carry a `required` error — + * used to build a specific "why is Save & Next disabled" message instead of a generic one. + * Controls not listed here (e.g. purely pattern-validated ones like panNumber/gstinNumber) + * only block when the user has actually typed something invalid, which is covered separately. */ + private static readonly REQUIRED_FIELD_LABELS: Record = { + kycDocumentName: 'KYC Document Type', + kycDocumentNumber: 'KYC Document Number', + amlCheckStatus: 'AML Check Status', + bannedEntityMatch: 'Banned Entity List Match' + }; + + /** Names every currently-invalid `kycForm` control (required-but-empty, or an entered value + * that fails its pattern) in plain English — surfaced via the Save & Next tooltip so staff + * aren't left staring at a greyed-out button with no clue what's still missing. Whether or + * not photo/signature previews are filled has no bearing on this list (they aren't part of + * `kycForm` at all — see `photoSigComplete`). */ + get invalidFieldLabels(): string[] { + if (!this.kycForm) { + return []; + } + return Object.keys(this.kycForm.controls) + .filter((key) => this.kycForm.get(key)?.invalid) + .map((key) => ClientKycStepComponent.REQUIRED_FIELD_LABELS[key] || key); + } /** Fallback document types when template API is unavailable */ private defaultDocumentTypes = [ { name: 'Aadhaar Card', code: 'AADHAAR' }, @@ -1366,6 +1393,25 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy { if (!input.files?.length) return; const file = input.files[0]; input.value = ''; + await this.openPhotoCropDialog(file); + } + + /** Webcam capture entry point for the profile photograph — feeds the captured frame + * through the same crop dialog + staging/upload path as the file-picker flow above, + * so behaviour (immediate upload in edit mode vs staged-until-submit in create mode) + * stays identical regardless of capture source. */ + async captureWebcamPhoto(): Promise { + const dialogRef = this.dialog.open(CaptureImageDialogComponent, { + data: { captureTarget: 'profile' } + }); + dialogRef.afterClosed().subscribe(async (imageDataUrl: string) => { + if (!imageDataUrl) return; + const file = dataUrlToFile(imageDataUrl, 'webcam-photograph.jpg'); + await this.openPhotoCropDialog(file); + }); + } + + private async openPhotoCropDialog(file: File): Promise { const { ImageCropDialogComponent } = await import('./image-crop-dialog/image-crop-dialog.component'); const dialogRef = this.dialog.open(ImageCropDialogComponent, { width: '680px', @@ -1391,6 +1437,24 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy { if (!input.files?.length) return; const file = input.files[0]; input.value = ''; + await this.openSignatureCropDialog(file); + } + + /** Webcam capture entry point for the specimen signature — photographs a paper + * signature, then reuses the same crop dialog + staging/upload path as the file-picker + * flow above (additive to the existing draw-pad / upload options, not a replacement). */ + async captureWebcamSignature(): Promise { + const dialogRef = this.dialog.open(CaptureImageDialogComponent, { + data: { captureTarget: 'signature' } + }); + dialogRef.afterClosed().subscribe(async (imageDataUrl: string) => { + if (!imageDataUrl) return; + const file = dataUrlToFile(imageDataUrl, 'webcam-signature.jpg'); + await this.openSignatureCropDialog(file); + }); + } + + private async openSignatureCropDialog(file: File): Promise { const { ImageCropDialogComponent } = await import('./image-crop-dialog/image-crop-dialog.component'); const dialogRef = this.dialog.open(ImageCropDialogComponent, { width: '680px', diff --git a/src/app/clients/clients-routing.module.ts b/src/app/clients/clients-routing.module.ts index b49c5b51c..9109e1003 100644 --- a/src/app/clients/clients-routing.module.ts +++ b/src/app/clients/clients-routing.module.ts @@ -22,6 +22,7 @@ import { AddFamilyMemberComponent } from './clients-view/family-members-tab/add- import { EditFamilyMemberComponent } from './clients-view/family-members-tab/edit-family-member/edit-family-member.component'; import { IdentitiesTabComponent } from './clients-view/identities-tab/identities-tab.component'; import { NotesTabComponent } from './clients-view/notes-tab/notes-tab.component'; +import { HistoryTabComponent } from './clients-view/history-tab/history-tab.component'; import { DocumentsTabComponent } from './clients-view/documents-tab/documents-tab.component'; import { DatatableTabComponent } from './clients-view/datatable-tab/datatable-tab.component'; import { AddressTabComponent } from './clients-view/address-tab/address-tab.component'; @@ -208,6 +209,11 @@ const routes: Routes = [ clientNotes: ClientNotesResolver } }, + { + path: 'history', + component: HistoryTabComponent, + data: { title: 'History', breadcrumb: 'History', routeParamBreadcrumb: false } + }, { path: 'datatables', children: [ diff --git a/src/app/clients/clients-view/clients-view.component.html b/src/app/clients/clients-view/clients-view.component.html index 775c195c8..a4cd5f25d 100644 --- a/src/app/clients/clients-view/clients-view.component.html +++ b/src/app/clients/clients-view/clients-view.component.html @@ -426,6 +426,9 @@ + @@ -559,6 +562,15 @@ > {{ 'labels.inputs.Notes' | translate }} + + History + @for (clientDatatable of clientDatatables; track clientDatatable) { { if (imageURL) { this.clientsService.uploadCapturedClientProfileImage(this.clientViewData.id, imageURL).subscribe(() => { @@ -401,6 +408,24 @@ export class ClientsViewComponent implements OnInit { }); } + /** + * Captures client signature via webcam (photograph of a paper signature). + * Mirrors `captureProfileImage()` — same dialog, parametrized for signature capture, + * uploaded via `uploadCapturedClientSignature()` instead of the profile-image upload. + */ + private captureSignatureImage() { + const captureImageDialogRef = this.dialog.open(CaptureImageDialogComponent, { + data: { captureTarget: 'signature' } + }); + captureImageDialogRef.afterClosed().subscribe((imageURL: string) => { + if (imageURL) { + this.clientsService.uploadCapturedClientSignature(this.clientViewData.id, imageURL).subscribe(() => { + this.reload(); + }); + } + }); + } + /** * Uploads the clients image. */ diff --git a/src/app/clients/clients-view/custom-dialogs/capture-image-dialog/capture-image-dialog.component.html b/src/app/clients/clients-view/custom-dialogs/capture-image-dialog/capture-image-dialog.component.html index e7613e151..7858bc85e 100644 --- a/src/app/clients/clients-view/custom-dialogs/capture-image-dialog/capture-image-dialog.component.html +++ b/src/app/clients/clients-view/custom-dialogs/capture-image-dialog/capture-image-dialog.component.html @@ -6,10 +6,24 @@ file, You can obtain one at http://mozilla.org/MPL/2.0/. --> -
-

{{ 'labels.heading.Capture Client Image' | translate }}

+
+

+ + {{ dialogTitle }} +

+

{{ dialogInstructions }}

- +
+ +
@@ -17,20 +31,20 @@

-
+
@if (!isCaptured) { - } @if (isCaptured) { - }
- + + @if (capturedFileName) { + {{ capturedFileName }} + } +
}
diff --git a/src/app/clients/clients-view/custom-dialogs/upload-document-dialog/upload-document-dialog.component.scss b/src/app/clients/clients-view/custom-dialogs/upload-document-dialog/upload-document-dialog.component.scss index 792e14fa7..cc9027d80 100644 --- a/src/app/clients/clients-view/custom-dialogs/upload-document-dialog/upload-document-dialog.component.scss +++ b/src/app/clients/clients-view/custom-dialogs/upload-document-dialog/upload-document-dialog.component.scss @@ -130,6 +130,41 @@ $white: #fff; } } +.fd-webcam-row { + display: flex; + align-items: center; + gap: 10px; + margin-top: 8px; +} + +.fd-webcam-divider { + font-size: 12px; + color: $gray-500; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.fd-webcam-btn { + border-radius: 8px !important; + font-weight: 500 !important; + + i { + margin-right: 6px; + } +} + +.fd-webcam-confirm { + display: flex; + align-items: center; + gap: 6px; + font-size: 13px; + color: #1e8e3e; + + i { + color: #1e8e3e; + } +} + .fd-section-divider { margin: 8px 0 12px; border-bottom: 1px solid $gray-200; diff --git a/src/app/clients/clients-view/custom-dialogs/upload-document-dialog/upload-document-dialog.component.ts b/src/app/clients/clients-view/custom-dialogs/upload-document-dialog/upload-document-dialog.component.ts index 22697da08..fb5b92c89 100644 --- a/src/app/clients/clients-view/custom-dialogs/upload-document-dialog/upload-document-dialog.component.ts +++ b/src/app/clients/clients-view/custom-dialogs/upload-document-dialog/upload-document-dialog.component.ts @@ -16,12 +16,14 @@ import { MatDialogClose } from '@angular/material/dialog'; import { ImageCropperDialogComponent } from '../image-cropper-dialog/image-cropper-dialog.component'; +import { CaptureImageDialogComponent } from '../capture-image-dialog/capture-image-dialog.component'; import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms'; import { FileUploadComponent } from '../../../../shared/file-upload/file-upload.component'; import { MatIconButton } from '@angular/material/button'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { ClientsService } from '../../../clients.service'; import { ProofType, normalizeProofType, groupProofsByCategory } from '../../../shared/kyc-proof-types'; +import { dataUrlToFile } from '../../../../shared/utils/data-url.util'; type SectionKey = 'id' | 'address'; @@ -72,6 +74,12 @@ export class UploadDocumentDialogComponent implements OnInit { addrMergedFile: File | null = null; addrMergedPreview: string | null = null; + /** Non-identifier (generic) mode only — set after a webcam capture, since + * `FileUploadComponent` only reflects files picked through its own native input and has + * no input for one supplied programmatically. Cleared if the user then picks a file via + * Browse instead, so only one confirmation shows at a time. */ + capturedFileName: string | null = null; + constructor() { const data = this.data; this.documentIdentifier = !!data?.documentIdentifier; @@ -390,6 +398,24 @@ export class UploadDocumentDialogComponent implements OnInit { if ($event.target.files.length > 0) { const file = $event.target.files[0]; this.uploadDocumentForm.get('file').setValue(file); + this.capturedFileName = null; } } + + /** Non-identifier (generic Documents tab) mode only — lets staff photograph a document + * with the webcam instead of only being able to browse for an existing file. Reuses the + * same capture dialog as the profile-photo/signature flows. */ + captureFromWebcam(): void { + const ref = this.dialog.open(CaptureImageDialogComponent, { + width: '720px', + maxWidth: '95vw', + data: { captureTarget: 'profile' } + }); + ref.afterClosed().subscribe((dataUrl: string | null) => { + if (!dataUrl) return; + const file = dataUrlToFile(dataUrl, `webcam-capture-${Date.now()}.jpg`); + this.uploadDocumentForm.get('file').setValue(file); + this.capturedFileName = file.name; + }); + } } diff --git a/src/app/clients/clients-view/custom-dialogs/view-signature-dialog/view-signature-dialog.component.html b/src/app/clients/clients-view/custom-dialogs/view-signature-dialog/view-signature-dialog.component.html index c70c76d4b..c78e6f759 100644 --- a/src/app/clients/clients-view/custom-dialogs/view-signature-dialog/view-signature-dialog.component.html +++ b/src/app/clients/clients-view/custom-dialogs/view-signature-dialog/view-signature-dialog.component.html @@ -34,5 +34,8 @@ + } diff --git a/src/app/clients/clients-view/custom-dialogs/view-signature-dialog/view-signature-dialog.component.ts b/src/app/clients/clients-view/custom-dialogs/view-signature-dialog/view-signature-dialog.component.ts index fe1b7ed67..155a8bc14 100644 --- a/src/app/clients/clients-view/custom-dialogs/view-signature-dialog/view-signature-dialog.component.ts +++ b/src/app/clients/clients-view/custom-dialogs/view-signature-dialog/view-signature-dialog.component.ts @@ -24,6 +24,7 @@ import { ClientsService } from 'app/clients/clients.service'; /** Node Types */ import { Buffer } from 'buffer'; import { CdkScrollable } from '@angular/cdk/scrolling'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; /** @@ -39,7 +40,8 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; CdkScrollable, MatDialogContent, MatDialogActions, - MatDialogClose + MatDialogClose, + FaIconComponent ] }) export class ViewSignatureDialogComponent implements OnInit { diff --git a/src/app/clients/clients-view/history-tab/history-tab.component.html b/src/app/clients/clients-view/history-tab/history-tab.component.html new file mode 100644 index 000000000..e15ff808b --- /dev/null +++ b/src/app/clients/clients-view/history-tab/history-tab.component.html @@ -0,0 +1,79 @@ + + +
+
+

Change History

+

Past changes to this client's details and address, pulled from the system Audit Trail.

+
+ +
+ @if (isLoading) { + + } + + @if (!isLoading && dataSource.data.length === 0) { +
No recorded changes for this client yet.
+ } + + @if (!isLoading && dataSource.data.length > 0) { + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Entity{{ row.entityName | translateKey: 'auditTrail.entities' }}Action + {{ row.actionName | translateKey: 'auditTrail.actions' }} + Fields Changed{{ fieldsChanged(row) || '—' }}Made By{{ row.maker }}Made On{{ row.madeOnDate | datetimeFormat }}Status + + {{ row.processingResult }} + +
+ + + } +
+
diff --git a/src/app/clients/clients-view/history-tab/history-tab.component.scss b/src/app/clients/clients-view/history-tab/history-tab.component.scss new file mode 100644 index 000000000..bdc9c4062 --- /dev/null +++ b/src/app/clients/clients-view/history-tab/history-tab.component.scss @@ -0,0 +1,108 @@ +/** + * 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/. + */ + +.history-tab { + padding: 16px 0; +} + +.history-tab__header { + margin-bottom: 16px; + + h3 { + margin: 0 0 4px; + font-size: 18px; + font-weight: 600; + } + + p { + margin: 0; + font-size: 13px; + color: rgb(0 0 0 / 60%); + } +} + +.history-tab__table-container { + position: relative; + background: #fff; + border-radius: 8px; + box-shadow: 0 1px 4px rgb(0 0 0 / 8%); + overflow-x: auto; +} + +.history-tab__loader { + position: absolute; + top: 0; + left: 0; + right: 0; +} + +.history-tab__empty { + padding: 32px 16px; + text-align: center; + color: rgb(0 0 0 / 55%); + font-size: 13.5px; +} + +.history-tab__table { + width: 100%; +} + +.history-tab__row { + cursor: pointer; + + &:hover { + background: #f8fafc; + } +} + +.history-tab__fields { + max-width: 320px; + white-space: normal; + font-size: 12.5px; + color: #475569; +} + +.history-tab__mono { + font-family: 'Roboto Mono', monospace; + font-size: 12.5px; +} + +.history-tab__action-badge { + display: inline-block; + padding: 2px 10px; + border-radius: 999px; + background: #eff6ff; + color: #1074b9; + font-size: 11.5px; + font-weight: 600; +} + +.history-tab__status { + display: inline-block; + padding: 2px 10px; + border-radius: 999px; + font-size: 11.5px; + font-weight: 700; + background: #f1f5f9; + color: #475569; + + &--success { + background: #dcfce7; + color: #15803d; + } + + &--failure { + background: #fee2e2; + color: #b91c1c; + } + + &--awaiting { + background: #fef3c7; + color: #92400e; + } +} diff --git a/src/app/clients/clients-view/history-tab/history-tab.component.ts b/src/app/clients/clients-view/history-tab/history-tab.component.ts new file mode 100644 index 000000000..4239e78a3 --- /dev/null +++ b/src/app/clients/clients-view/history-tab/history-tab.component.ts @@ -0,0 +1,150 @@ +/** + * 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/. + */ + +/** Angular Imports */ +import { Component, OnInit, ViewChild, inject } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { MatPaginator } from '@angular/material/paginator'; +import { + MatTableDataSource, + MatTable, + MatColumnDef, + MatHeaderCellDef, + MatHeaderCell, + MatCellDef, + MatCell, + MatHeaderRowDef, + MatHeaderRow, + MatRowDef, + MatRow +} from '@angular/material/table'; +import { MatProgressBar } from '@angular/material/progress-bar'; + +/** rxjs Imports */ +import { forkJoin } from 'rxjs'; + +/** Custom Services */ +import { SystemService } from 'app/system/system.service'; +import { DatetimeFormatPipe } from '../../../pipes/datetime-format.pipe'; +import { TranslatePipe } from '../../../pipes/translate.pipe'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +/** + * Client "History" tab — a read-only, client-scoped view over the EXISTING system-wide + * Audit Trail data (no new backend endpoint). Reuses `SystemService.getAuditTrails()` + * exactly as `AuditTrailsComponent` does, pre-filtered to `entityName IN (CLIENT, ADDRESS)` + * + `resourceId = clientId` — two calls (the underlying `filterBy` shape only carries one + * `entityName` value at a time) merged and sorted client-side. + */ +@Component({ + selector: 'mifosx-clients-history-tab', + templateUrl: './history-tab.component.html', + styleUrls: ['./history-tab.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + MatProgressBar, + MatTable, + MatColumnDef, + MatHeaderCellDef, + MatHeaderCell, + MatCellDef, + MatCell, + MatHeaderRowDef, + MatHeaderRow, + MatRowDef, + MatRow, + MatPaginator, + DatetimeFormatPipe, + TranslatePipe + ] +}) +export class HistoryTabComponent implements OnInit { + private route = inject(ActivatedRoute); + private systemService = inject(SystemService); + + /** Client ID (from the parent `:clientId` route param, same pattern as notes-tab). */ + clientId: string; + + isLoading = false; + displayedColumns: string[] = [ + 'entityName', + 'actionName', + 'fieldsChanged', + 'maker', + 'madeOnDate', + 'processingResult' + ]; + dataSource: MatTableDataSource = new MatTableDataSource([]); + + @ViewChild(MatPaginator) paginator: MatPaginator; + + constructor() { + this.clientId = this.route.parent.snapshot.params['clientId']; + } + + ngOnInit(): void { + this.loadHistory(); + } + + private auditFilterFor(entityName: string): { type: string; value: string }[] { + return [ + { type: 'entityName', value: entityName }, + { type: 'resourceId', value: this.clientId } + ]; + } + + private loadHistory(): void { + this.isLoading = true; + forkJoin({ + client: this.systemService.getAuditTrails(this.auditFilterFor('CLIENT'), '', '', 0, 200), + address: this.systemService.getAuditTrails(this.auditFilterFor('ADDRESS'), '', '', 0, 200) + }).subscribe({ + next: ({ client, address }: { client: any; address: any }) => { + const merged = [ + ...(client?.pageItems || []), + ...(address?.pageItems || []) + ]; + // Most recent first — audit trail ids are monotonically increasing. + merged.sort((a, b) => (b?.id ?? 0) - (a?.id ?? 0)); + this.dataSource = new MatTableDataSource(merged); + this.dataSource.paginator = this.paginator; + this.isLoading = false; + }, + error: () => { + this.isLoading = false; + } + }); + } + + /** + * Summarises which fields a row changed, parsed from `commandAsJson` — same source + * `ViewAuditComponent` parses for its command/value table, just condensed to a + * comma-separated field list for this compact history view. Boilerplate command + * params (locale/dateFormat) are excluded as noise. + */ + fieldsChanged(row: any): string { + if (!row?.commandAsJson) { + return ''; + } + try { + const parsed = JSON.parse(row.commandAsJson); + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + const ignore = new Set([ + 'locale', + 'dateFormat' + ]); + return Object.keys(parsed) + .filter((key) => !ignore.has(key)) + .join(', '); + } + } catch { + // Malformed/absent commandAsJson — leave blank rather than throw. + } + return ''; + } +} diff --git a/src/app/clients/clients.service.ts b/src/app/clients/clients.service.ts index 0d7be9314..4e1d04a2d 100644 --- a/src/app/clients/clients.service.ts +++ b/src/app/clients/clients.service.ts @@ -16,6 +16,7 @@ import { switchMap } from 'rxjs/operators'; import { map, catchError } from 'rxjs/operators'; import { environment } from 'environments/environment'; +import { dataUrlToFile } from 'app/shared/utils/data-url.util'; /** * Clients service. @@ -230,6 +231,63 @@ export class ClientsService { return this.http.get(`/clients/${clientId}/documents/${documentId}/attachment`, { responseType: 'blob' }); } + /** + * Uploads a webcam-captured signature (photograph of a paper signature). + * Mirrors `uploadClientSignatureImage()`'s multipart shape (name=clientSignature) + * exactly, but the source is a captured `data:` URL rather than a picked `File`. + */ + uploadCapturedClientSignature(clientId: string, imageDataUrl: string) { + const formData = new FormData(); + formData.append('file', dataUrlToFile(imageDataUrl, 'clientSignature.jpg')); + formData.append('name', 'clientSignature'); + formData.append('description', 'Client signature (captured via webcam)'); + return this.http.post(`/clients/${clientId}/documents`, formData); + } + + /** + * Generates an OTP against a client's OLD or NEW mobile/email destination, ahead of a + * sensitive contact-detail change. Dual-OTP verification (old + new) is required before + * the mobileNo/emailAddress change is accepted by the backend — see `verifyClientContactOtp()`. + * @param clientId Client ID + * @param channel 'MOBILE' | 'EMAIL' + * @param destination Raw mobile number or email address to send the OTP to + */ + sendClientContactOtp(clientId: string, channel: 'MOBILE' | 'EMAIL', destination: string): Observable { + return this.http.post(`/clients/${clientId}/contact-verification/otp`, { channel, destination }); + } + + /** + * Verifies an OTP previously generated via `sendClientContactOtp()`. + * @param clientId Client ID + * @param otpId ID returned by the generate call + * @param otp 6-digit code entered by staff on the customer's behalf + */ + verifyClientContactOtp(clientId: string, otpId: number, otp: string): Observable { + return this.http.post(`/clients/${clientId}/contact-verification/otp/verify`, { otpId, otp }); + } + + /** + * Generates an OTP against a NOT-YET-CREATED client's mobile/email, ahead of the initial + * `POST /clients` call. There is no clientId yet, so this hits the clientId-less sibling + * endpoint (`ClientCreationContactVerificationApiResource`) rather than + * `sendClientContactOtp()`'s `/clients/{clientId}/...` shape. The returned otpId is sent + * back as `mobileNewOtpId`/`emailNewOtpId` on `createClient()`. + * @param channel 'MOBILE' | 'EMAIL' + * @param destination Raw mobile number or email address to send the OTP to + */ + sendClientCreationContactOtp(channel: 'MOBILE' | 'EMAIL', destination: string): Observable { + return this.http.post(`/clients/contact-verification/otp`, { channel, destination }); + } + + /** + * Verifies an OTP previously generated via `sendClientCreationContactOtp()`. + * @param otpId ID returned by the generate call + * @param otp 6-digit code entered by staff on the customer's behalf + */ + verifyClientCreationContactOtp(otpId: number, otp: string): Observable { + return this.http.post(`/clients/contact-verification/otp/verify`, { otpId, otp }); + } + getClientFamilyMembers(clientId: string) { return this.http.get(`/clients/${clientId}/familymembers`); } diff --git a/src/app/clients/create-client/create-client.component.ts b/src/app/clients/create-client/create-client.component.ts index 3b58d01bd..976918766 100644 --- a/src/app/clients/create-client/create-client.component.ts +++ b/src/app/clients/create-client/create-client.component.ts @@ -192,7 +192,10 @@ export class CreateClientComponent { if (idx === 0) { if (!this.clientGeneralStep || !this.clientGeneralForm) return false; if (this.clientGeneralStep.sanctionsBlocked) return false; - return this.clientGeneralForm.valid; + if (!this.clientGeneralForm.valid) return false; + // Contact-OTP gate: mobileNo/emailAddress must each be OTP-verified before a brand-new + // client can be created — mirrors the edit flow's dual-OTP gate in EditClientComponent. + return this.clientGeneralStep.contactVerified; } if (idx === 1) { // KYC tab — block when the kyc form has unfilled required fields @@ -241,6 +244,15 @@ export class CreateClientComponent { if (idx === 0 && this.clientGeneralStep?.sanctionsBlocked) { return 'Client failed sanctions screening — cannot proceed.'; } + if (idx === 0 && this.clientGeneralForm?.valid && !this.clientGeneralStep?.contactVerified) { + return 'Verify the OTP sent to both the mobile number and email address before proceeding.'; + } + if (idx === 1) { + const labels = this.clientKycStep?.invalidFieldLabels || []; + if (labels.length) { + return `Please fill/correct: ${labels.join(', ')}.`; + } + } return 'Please fill all mandatory fields on this tab to continue.'; } @@ -309,6 +321,16 @@ export class CreateClientComponent { return; } + // Contact-OTP gate — defensive re-check. The Save & Next button is already disabled via + // `currentStepValid` while this is incomplete, but don't rely on that alone (mirrors the + // same defensive re-check in EditClientComponent.confirmAndSaveTab()). + if (!this.clientGeneralStep?.contactVerified) { + this.snackBar.open('Verify the OTP sent to both the mobile number and email address before saving.', 'Close', { + duration: 5000 + }); + return; + } + // Sanctions verification gate — Verify Client must be clicked before save if (!this.clientGeneralStep?.sanctionsVerified) { this.dialog @@ -426,6 +448,12 @@ export class CreateClientComponent { const dateFormat = this.settingsService.dateFormat; const clientData = { ...this.client, dateFormat, locale }; + // Mandatory contact-OTP references — required by POST /clients whenever mobileNo/ + // emailAddress are present (they always are, both are Validators.required on this form). + // Gated by `contactVerified` above, so these are guaranteed non-null at this point. + clientData.mobileNewOtpId = this.clientGeneralStep?.mobileNewOtpId; + clientData.emailNewOtpId = this.clientGeneralStep?.emailNewOtpId; + if (this.clientTemplate.datatables && this.clientTemplate.datatables.length > 0) { const datatables: any[] = []; this.clientDatatables?.forEach((clientDatatable: ClientDatatableStepComponent) => { @@ -493,12 +521,19 @@ export class CreateClientComponent { this.finalizeClient(this.createdClientId); } else { // Fallback: create client now (shouldn't happen in normal flow) + if (!this.clientGeneralStep?.contactVerified) { + this.validationError = 'Verify the OTP sent to both the mobile number and email address before submitting.'; + this.selectedTabIndex = 0; + return; + } const locale = this.settingsService.language.code; const dateFormat = this.settingsService.dateFormat; const clientData = { ...this.client, dateFormat, - locale + locale, + mobileNewOtpId: this.clientGeneralStep?.mobileNewOtpId, + emailNewOtpId: this.clientGeneralStep?.emailNewOtpId }; if (this.clientTemplate.datatables && this.clientTemplate.datatables.length > 0) { diff --git a/src/app/clients/edit-client/contact-otp-block/contact-otp-block.component.html b/src/app/clients/edit-client/contact-otp-block/contact-otp-block.component.html new file mode 100644 index 000000000..e02507ce2 --- /dev/null +++ b/src/app/clients/edit-client/contact-otp-block/contact-otp-block.component.html @@ -0,0 +1,76 @@ + + +
+
+ {{ label }} + {{ maskedDestination }} +
+ + @if (verified) { +
+ + Verified +
+ } @else if (!otpSent) { + + } @else { +
+ @for (i of digitIndexes; track i) { + + } +
+ +
+ + +
+ } + + @if (errorMessage) { +
+ + {{ errorMessage }} +
+ } +
diff --git a/src/app/clients/edit-client/contact-otp-block/contact-otp-block.component.scss b/src/app/clients/edit-client/contact-otp-block/contact-otp-block.component.scss new file mode 100644 index 000000000..abba30455 --- /dev/null +++ b/src/app/clients/edit-client/contact-otp-block/contact-otp-block.component.scss @@ -0,0 +1,177 @@ +/** + * 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/. + */ + +/* Card with 6 individually-bordered digit boxes (not the single-input/background-gradient + * trick used elsewhere in this app) — each box is its own , so focus/border state is + * per-digit rather than shared across the whole code. */ + +:host { + display: block; +} + +.cotp-block { + display: flex; + flex-direction: column; + align-items: center; + gap: 20px; + padding: 28px 24px; + border: 1px solid #e7ecf3; + border-radius: 18px; + background: #fff; + box-shadow: 0 8px 24px -8px rgb(15 23 42 / 10%); + text-align: center; + + &--verified { + border-color: #86efac; + background: #f7fdf9; + } +} + +.cotp-block__header { + display: flex; + flex-direction: column; + gap: 6px; +} + +.cotp-block__label { + font-size: 15px; + font-weight: 800; + text-transform: uppercase; + letter-spacing: 0.4px; + color: #1e293b; +} + +.cotp-block__target { + font-size: 15px; + font-weight: 700; + color: #1074b9; + word-break: break-all; +} + +.cotp-block__verified { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 7px 16px; + border-radius: 999px; + background: #dcfce7; + color: #15803d; + font-size: 13px; + font-weight: 700; +} + +.cotp-block__actions { + display: flex; + justify-content: center; + gap: 12px; + width: 100%; +} + +.cotp-otp { + display: flex; + justify-content: center; + gap: 10px; +} + +.cotp-otp__box { + box-sizing: border-box; + width: 46px; + height: 52px; + border: 1.5px solid #dde3ec; + border-radius: 12px; + background: #fff; + color: #0f172a; + font-family: 'SF Mono', 'Cascadia Code', Consolas, monospace; + font-size: 22px; + font-weight: 700; + text-align: center; + caret-color: #1074b9; + outline: none; + transition: + border-color 0.15s, + box-shadow 0.15s; + + &:hover { + border-color: #b7c2d0; + } + + &:focus { + border-color: #1074b9; + box-shadow: 0 0 0 3px rgb(16 116 185 / 15%); + } +} + +.cotp-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + flex: 1 1 0; + height: 48px; + padding: 0 20px; + border-radius: 999px; + font-size: 14px; + font-weight: 700; + cursor: pointer; + border: 1.5px solid transparent; + transition: + background 0.15s, + border-color 0.15s, + color 0.15s, + opacity 0.15s; + + &:disabled { + cursor: not-allowed; + opacity: 0.55; + } + + &--primary { + background: #1074b9; + color: #fff; + box-shadow: 0 6px 16px -4px rgb(16 116 185 / 45%); + + &:hover:not(:disabled) { + background: #0d5f96; + } + } + + &--ghost { + background: #fff; + color: #1074b9; + border-color: #cfe0ee; + + &:hover:not(:disabled) { + border-color: #1074b9; + background: #f0f7fc; + } + } + + mat-progress-spinner ::ng-deep circle { + stroke: currentcolor !important; + } +} + +.cotp-error { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 14px; + border-radius: 10px; + background: #fef2f2; + border: 1px solid #fecaca; + color: #b91c1c; + font-size: 12.5px; +} + +@media (width <= 420px) { + .cotp-otp__box { + width: 38px; + height: 46px; + font-size: 18px; + } +} diff --git a/src/app/clients/edit-client/contact-otp-block/contact-otp-block.component.ts b/src/app/clients/edit-client/contact-otp-block/contact-otp-block.component.ts new file mode 100644 index 000000000..7d01f68d9 --- /dev/null +++ b/src/app/clients/edit-client/contact-otp-block/contact-otp-block.component.ts @@ -0,0 +1,307 @@ +/** + * 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/. + */ + +/** Angular Imports */ +import { + Component, + ElementRef, + EventEmitter, + Input, + OnChanges, + OnDestroy, + Output, + QueryList, + SimpleChanges, + ViewChildren, + inject +} from '@angular/core'; +import { UntypedFormControl } from '@angular/forms'; +import { MatProgressSpinner } from '@angular/material/progress-spinner'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +/** Custom Services */ +import { ClientsService } from '../../clients.service'; + +/** Emitted every time this block's verification result changes — including a reset back + * to `{ otpId: null, verified: false }` when the destination it was verifying no longer + * matches (e.g. staff kept editing the mobile number after verifying an earlier value). */ +export interface ContactOtpState { + otpId: number | null; + verified: boolean; +} + +/** + * Small presentational OTP-entry block reused up to four times inside the Edit Client + * "Verify Contact Change" panel (old mobile / new mobile / old email / new email — see + * `EditClientComponent`), and once each for mobile/email in the Create Client flow (see + * `ClientGeneralStepComponent`). Renders as 6 individually-bordered digit boxes (one real + * `` each, auto-advancing focus) rather than the single-input/background-gradient trick + * used elsewhere in this app — each box needs its own focus/border state, which a shared input + * can't express. + */ +@Component({ + selector: 'mifosx-client-contact-otp-block', + templateUrl: './contact-otp-block.component.html', + styleUrls: ['./contact-otp-block.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + FaIconComponent, + MatProgressSpinner + ] +}) +export class ContactOtpBlockComponent implements OnChanges, OnDestroy { + private clientsService = inject(ClientsService); + + /** The 6 individual digit-box elements, in order — used to move focus between them. */ + @ViewChildren('digitBox') digitBoxes: QueryList>; + + /** One independent control per box — NOT a single 6-char string sliced up. Editing box 0 + * can never disturb box 3's value this way. (An earlier version kept one shared string and + * rebuilt it via `chars.join('')` on every keystroke — `['', '2', '3'].join('') === '23'` + * silently drops empty slots instead of preserving them as gaps, so deleting an early digit + * collapsed/shifted every digit after it. Per-box controls sidestep that class of bug + * entirely — there's no string to rebuild until the moment we actually submit.) */ + readonly digitControls: UntypedFormControl[] = Array.from({ length: 6 }, () => new UntypedFormControl('')); + + /** Fixed-length array so the template can `*ngFor` over exactly 6 boxes. */ + readonly digitIndexes = [ + 0, + 1, + 2, + 3, + 4, + 5 + ]; + + /** Client whose mobileNo/emailAddress is being changed. Omit this input entirely for the + * CLIENT CREATION flow — there is no client id yet, so the block falls back to the + * clientId-less creation-time OTP endpoints (`ClientsService.sendClientCreationContactOtp()` / + * `verifyClientCreationContactOtp()`) instead of the edit-flow `/clients/{clientId}/...` ones. */ + @Input() clientId?: string; + /** Which LincPay OTP channel to use. */ + @Input() channel: 'MOBILE' | 'EMAIL'; + /** Raw destination (old or new mobile number / email address) this block verifies. */ + @Input() destination = ''; + /** Display label, e.g. "Old Mobile Number" / "New Email Address". */ + @Input() label = ''; + + /** Fires whenever `{ otpId, verified }` changes — including resets. */ + @Output() stateChange = new EventEmitter(); + + otpId: number | null = null; + otpSent = false; + verified = false; + sending = false; + verifying = false; + errorMessage = ''; + resendCountdown = 0; + private resendTimer: ReturnType | null = null; + + /** The full 6-digit code, assembled fresh from the 6 independent box controls only when + * actually needed (display never needs this — each box renders its own control's value). */ + get code(): string { + return this.digitControls.map((c) => c.value || '').join(''); + } + + get codeValid(): boolean { + return /^\d{6}$/.test(this.code); + } + + ngOnChanges(changes: SimpleChanges): void { + // If the value being verified changes after the fact (staff kept typing), any + // previously-sent/verified OTP no longer applies to the current destination. + if (changes['destination'] && !changes['destination'].firstChange && this.otpSent) { + this.resetState(); + } + } + + ngOnDestroy(): void { + if (this.resendTimer) { + clearInterval(this.resendTimer); + } + } + + /** Masks the destination so only a few characters are visible, mirroring the masking + * convention already used on the login 2FA screen (`maskTarget()`). */ + get maskedDestination(): string { + const value = this.destination || ''; + if (!value) { + return ''; + } + if (this.channel === 'MOBILE') { + const digits = value.replace(/\D/g, ''); + if (digits.length <= 4) { + return value; + } + return 'X'.repeat(digits.length - 4) + digits.slice(-4); + } + const at = value.indexOf('@'); + if (at <= 0) { + return value; + } + const local = value.substring(0, at); + const domain = value.substring(at); + const visible = local.substring(0, Math.min(2, local.length)); + const hidden = 'X'.repeat(Math.max(0, local.length - visible.length)); + return visible + hidden + domain; + } + + sendOtp(): void { + if (!this.destination || this.sending) { + return; + } + this.sending = true; + this.errorMessage = ''; + const request$ = this.clientId + ? this.clientsService.sendClientContactOtp(this.clientId, this.channel, this.destination) + : this.clientsService.sendClientCreationContactOtp(this.channel, this.destination); + request$.subscribe({ + next: (res: any) => { + this.sending = false; + this.otpId = res?.otpId ?? res?.resourceId ?? null; + this.otpSent = true; + this.clearAllDigits(); + this.startResendCountdown(); + // The 6 digit boxes only render once otpSent flips true — defer the focus call to the + // next tick so they exist in the DOM (and in `digitBoxes`) by the time it runs. + setTimeout(() => this.focusBox(0)); + if (this.otpId == null) { + this.errorMessage = 'OTP sent, but no reference id was returned. Please contact support.'; + } + }, + error: (err: any) => { + this.sending = false; + this.errorMessage = err?.error?.defaultUserMessage || err?.error?.message || 'Could not send OTP.'; + } + }); + } + + /** Typing a digit in box `index`: this box's own control gets exactly that digit — every + * other box's control is untouched, so there's nothing to shift or collapse. Moves focus to + * the next box unless this was the last one. */ + onDigitInput(index: number, rawValue: string): void { + const digit = (rawValue || '').replace(/\D/g, '').slice(-1); + this.digitControls[index].setValue(digit); + + if (digit && index < 5) { + this.focusBox(index + 1); + } + } + + /** Backspace in an empty box clears the PREVIOUS box and moves focus there, matching the + * usual "segmented OTP input" keyboard feel — again, only ever touching one control at a + * time. Arrow keys move focus without altering any box's value. */ + onDigitKeydown(index: number, event: KeyboardEvent): void { + if (event.key === 'Backspace') { + if (!this.digitControls[index].value && index > 0) { + this.digitControls[index - 1].setValue(''); + this.focusBox(index - 1); + event.preventDefault(); + } + return; + } + if (event.key === 'ArrowLeft' && index > 0) { + this.focusBox(index - 1); + event.preventDefault(); + } else if (event.key === 'ArrowRight' && index < 5) { + this.focusBox(index + 1); + event.preventDefault(); + } + } + + /** Pasting a full code into any box fills all 6 boxes at once (one control per digit) and + * focuses the last filled one. */ + onDigitPaste(event: ClipboardEvent): void { + const pasted = event.clipboardData?.getData('text') || ''; + const digits = pasted.replace(/\D/g, '').slice(0, 6); + if (!digits) { + return; + } + event.preventDefault(); + digits.split('').forEach((d, i) => this.digitControls[i].setValue(d)); + for (let i = digits.length; i < 6; i++) { + this.digitControls[i].setValue(''); + } + this.focusBox(Math.min(digits.length, 6) - 1); + } + + private clearAllDigits(): void { + this.digitControls.forEach((c) => c.setValue('')); + } + + private focusBox(index: number): void { + const box = this.digitBoxes?.toArray()[index]; + box?.nativeElement.focus(); + } + + resendOtp(): void { + if (this.resendCountdown > 0 || this.sending) { + return; + } + this.sendOtp(); + } + + verifyOtp(): void { + if (this.otpId == null || !this.codeValid || this.verifying) { + this.digitControls.forEach((c) => c.markAsTouched()); + return; + } + this.verifying = true; + this.errorMessage = ''; + const request$ = this.clientId + ? this.clientsService.verifyClientContactOtp(this.clientId, this.otpId, this.code) + : this.clientsService.verifyClientCreationContactOtp(this.otpId, this.code); + request$.subscribe({ + next: (res: any) => { + this.verifying = false; + // Defensive shape-tolerance: treat an explicit `verified: false` as failure, + // anything else (2xx with `verified: true`, or no `verified` field at all) as success. + const ok = res?.verified !== false; + if (ok) { + this.verified = true; + this.stateChange.emit({ otpId: this.otpId, verified: true }); + } else { + this.errorMessage = res?.message || 'Incorrect OTP — please try again.'; + } + }, + error: (err: any) => { + this.verifying = false; + this.errorMessage = err?.error?.defaultUserMessage || err?.error?.message || 'OTP verification failed.'; + } + }); + } + + private resetState(): void { + this.otpId = null; + this.otpSent = false; + this.verified = false; + this.errorMessage = ''; + this.clearAllDigits(); + if (this.resendTimer) { + clearInterval(this.resendTimer); + this.resendTimer = null; + } + this.resendCountdown = 0; + this.stateChange.emit({ otpId: null, verified: false }); + } + + private startResendCountdown(): void { + this.resendCountdown = 30; + if (this.resendTimer) { + clearInterval(this.resendTimer); + } + this.resendTimer = setInterval(() => { + this.resendCountdown--; + if (this.resendCountdown <= 0 && this.resendTimer) { + clearInterval(this.resendTimer); + this.resendTimer = null; + } + }, 1000); + } +} diff --git a/src/app/clients/edit-client/edit-client.component.html b/src/app/clients/edit-client/edit-client.component.html index 08e14add8..87d01e65a 100644 --- a/src/app/clients/edit-client/edit-client.component.html +++ b/src/app/clients/edit-client/edit-client.component.html @@ -42,6 +42,79 @@ [readOnly]="isTabReadOnly(0)" (legalFormChangeEvent)="legalFormChange($event)" > + + + @if (!isTabReadOnly(0) && (contactChanges.mobileChanged || contactChanges.emailChanged)) { +
+
+ verified_user +
+ Verify Contact Change + An OTP must be verified on both the old and new + {{ + contactChanges.mobileChanged && contactChanges.emailChanged + ? 'mobile number and email address' + : contactChanges.mobileChanged + ? 'mobile number' + : 'email address' + }} + before this change can be saved. +
+
+ @if (contactChanges.mobileChanged) { +
+
+ phone_iphone + Mobile Number +
+
+ + +
+
+ } + @if (contactChanges.emailChanged) { +
+
+ email + Email Address +
+
+ + +
+
+ } +
+ }
diff --git a/src/app/clients/edit-client/edit-client.component.scss b/src/app/clients/edit-client/edit-client.component.scss index 771d5894b..537b656ef 100644 --- a/src/app/clients/edit-client/edit-client.component.scss +++ b/src/app/clients/edit-client/edit-client.component.scss @@ -150,6 +150,73 @@ border: 1px solid #fca5a5 !important; } +/* ─── Dual-OTP "Verify Contact Change" panel ─── */ +.contact-verify-panel { + margin-top: 20px; + padding: 16px 18px; + border: 1px solid #bfdbfe; + border-radius: 8px; + background: #eff6ff; + + &__header { + display: flex; + align-items: flex-start; + gap: 12px; + margin-bottom: 14px; + color: #1074b9; + + mat-icon { + margin-top: 1px; + flex-shrink: 0; + } + } + + &__header-text { + display: flex; + flex-direction: column; + gap: 2px; + font-size: 13.5px; + line-height: 1.5; + color: #1e3a5f; + + strong { + font-size: 14px; + font-weight: 700; + } + } + + &__field-group { + &:not(:last-child) { + margin-bottom: 18px; + padding-bottom: 18px; + border-bottom: 1px dashed #bfdbfe; + } + } + + &__field-title { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 8px; + font-size: 13px; + font-weight: 700; + color: #1e3a5f; + + mat-icon { + font-size: 17px; + width: 17px; + height: 17px; + color: #1074b9; + } + } + + &__grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 12px; + } +} + /* ─── Re-KYC Alert Banner ─── */ .rekyc-alert { display: flex; diff --git a/src/app/clients/edit-client/edit-client.component.ts b/src/app/clients/edit-client/edit-client.component.ts index 432d7c35d..fbfd360f7 100644 --- a/src/app/clients/edit-client/edit-client.component.ts +++ b/src/app/clients/edit-client/edit-client.component.ts @@ -10,6 +10,7 @@ import { Component, QueryList, ViewChild, ViewChildren, inject } from '@angular/core'; import { Router, ActivatedRoute } from '@angular/router'; import { MatDialog } from '@angular/material/dialog'; +import { MatSnackBar } from '@angular/material/snack-bar'; import { Observable, of } from 'rxjs'; import { map } from 'rxjs/operators'; @@ -28,6 +29,7 @@ import { ClientDatatableStepComponent } from '../client-stepper/client-datatable import { ClientKycStepComponent } from '../client-stepper/client-kyc-step/client-kyc-step.component'; import { ClientPreviewStepComponent } from '../client-stepper/client-preview-step/client-preview-step.component'; import { ClientFamilyMembersStepComponent } from '../client-stepper/client-family-members-step/client-family-members-step.component'; +import { ContactOtpBlockComponent, ContactOtpState } from './contact-otp-block/contact-otp-block.component'; import { MatTabsModule } from '@angular/material/tabs'; import { MatIconModule } from '@angular/material/icon'; @@ -59,7 +61,8 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; ClientDatatableStepComponent, ClientKycStepComponent, ClientPreviewStepComponent, - ClientFamilyMembersStepComponent + ClientFamilyMembersStepComponent, + ContactOtpBlockComponent ] }) export class EditClientComponent { @@ -68,6 +71,7 @@ export class EditClientComponent { private dialog = inject(MatDialog); private clientsService = inject(ClientsService); private settingsService = inject(SettingsService); + private snackBar = inject(MatSnackBar); @ViewChild(ClientGeneralStepComponent) clientGeneralStep: ClientGeneralStepComponent; @ViewChild('clientAddress') clientAddressStep: ClientAddressStepComponent; @@ -88,6 +92,26 @@ export class EditClientComponent { /** Flag: DOB was changed — requires re-KYC (PAN re-verification) */ dobChanged = false; + /** + * Dual-OTP contact-change verification state (mobileNo / emailAddress). Mirrors the + * name/DOB "detect change against snapshot, gate the save" pattern already established + * in this component (see `detectSensitiveFieldChanges()` / `flagReKycRequired()`) — + * extended here so a changed mobile/email must be OTP-verified on BOTH the old and new + * destination before Save is allowed. Payload param names sent to the backend PUT are + * `mobileOldOtpId` / `mobileNewOtpId` / `emailOldOtpId` / `emailNewOtpId` (added onto + * `clientData` in `saveCurrentTab()`'s General-tab branch) — kept per-field rather than + * one generic old/new pair so mobile AND email can both be changed (and independently + * OTP-verified) in a single save. + */ + oldMobileOtpId: number | null = null; + newMobileOtpId: number | null = null; + oldMobileVerified = false; + newMobileVerified = false; + oldEmailOtpId: number | null = null; + newEmailOtpId: number | null = null; + oldEmailVerified = false; + newEmailVerified = false; + /** Client data and template (merged object from resolver) */ clientDataAndTemplate: any; /** Client ID */ @@ -168,6 +192,61 @@ export class EditClientComponent { return { ...general }; } + /** + * Live view of `detectSensitiveFieldChanges()` against the loaded snapshot — drives the + * "Verify Contact Change" OTP panel in the General tab template. Re-evaluated on every + * change-detection pass (cheap: a handful of form-control reads), so the panel appears/ + * disappears as the user types, same as the existing re-KYC name/DOB detection. + */ + get contactChanges() { + return this.detectSensitiveFieldChanges(this.clientDataAndTemplate); + } + + /** Whether every OTP block required by the fields that actually changed has been verified. */ + get contactVerificationComplete(): boolean { + const changes = this.contactChanges; + if (changes.mobileChanged && !(this.oldMobileVerified && this.newMobileVerified)) { + return false; + } + if (changes.emailChanged && !(this.oldEmailVerified && this.newEmailVerified)) { + return false; + } + return true; + } + + onOldMobileOtpState(state: ContactOtpState): void { + this.oldMobileOtpId = state.otpId; + this.oldMobileVerified = state.verified; + } + + onNewMobileOtpState(state: ContactOtpState): void { + this.newMobileOtpId = state.otpId; + this.newMobileVerified = state.verified; + } + + onOldEmailOtpState(state: ContactOtpState): void { + this.oldEmailOtpId = state.otpId; + this.oldEmailVerified = state.verified; + } + + onNewEmailOtpState(state: ContactOtpState): void { + this.newEmailOtpId = state.otpId; + this.newEmailVerified = state.verified; + } + + /** Reset all dual-OTP state — called once a General-tab save succeeds (the snapshot is + * updated to match, so the "Verify Contact Change" panel naturally disappears too). */ + private resetContactVerificationState(): void { + this.oldMobileOtpId = null; + this.newMobileOtpId = null; + this.oldMobileVerified = false; + this.newMobileVerified = false; + this.oldEmailOtpId = null; + this.newEmailOtpId = null; + this.oldEmailVerified = false; + this.newEmailVerified = false; + } + areFormvalids(): boolean { if (!this.clientGeneralStep || !this.clientGeneralForm) { return false; @@ -196,7 +275,10 @@ export class EditClientComponent { if (idx === 0) { if (!this.clientGeneralStep || !this.clientGeneralForm) return false; if (this.clientGeneralStep.sanctionsBlocked) return false; - return this.clientGeneralForm.valid; + if (!this.clientGeneralForm.valid) return false; + // Dual-OTP gate: a changed mobile/email must be verified on both old & new + // destinations before Save is allowed — mirrors the re-KYC gate below it. + return this.contactVerificationComplete; } if (idx === 1) { const kf = this.clientKycStep?.kycForm; @@ -227,6 +309,15 @@ export class EditClientComponent { if (this.selectedTabIndex === 0 && this.clientGeneralStep?.sanctionsBlocked) { return 'Client failed sanctions screening — cannot proceed.'; } + if (this.selectedTabIndex === 0 && this.clientGeneralForm?.valid && !this.contactVerificationComplete) { + return 'Verify the OTP sent to both the old and new mobile number/email before saving.'; + } + if (this.selectedTabIndex === 1) { + const labels = this.clientKycStep?.invalidFieldLabels || []; + if (labels.length) { + return `Please fill/correct: ${labels.join(', ')}.`; + } + } return 'Please fill all mandatory fields on this tab to continue.'; } @@ -272,6 +363,16 @@ export class EditClientComponent { // Check if name or DOB changed on the General tab — warn about re-KYC if (this.selectedTabIndex === 0) { const changes = this.detectSensitiveFieldChanges(this.clientDataAndTemplate); + + // Dual-OTP gate — defensive re-check. The "Save & Next" button is already disabled + // via `currentStepValid` while this is incomplete, but don't rely on that alone. + if (!this.contactVerificationComplete) { + this.snackBar.open('Verify the OTP sent to both the old and new mobile number/email before saving.', 'Close', { + duration: 5000 + }); + return; + } + if (changes.nameChanged || changes.dobChanged) { heading = 'Re-KYC Required'; dialogContext = @@ -308,6 +409,19 @@ export class EditClientComponent { const originalData = this.clientDataAndTemplate; const changedFields = this.detectSensitiveFieldChanges(originalData); + // Dual-OTP verified ids — sent only for the field(s) that actually changed, per-field + // (not a single generic old/new pair) so the backend can tell mobile's OTPs apart from + // email's when BOTH changed in the same save. See the class-level JSDoc above the OTP + // state fields for the full naming rationale. + if (changedFields.mobileChanged) { + clientData.mobileOldOtpId = this.oldMobileOtpId; + clientData.mobileNewOtpId = this.newMobileOtpId; + } + if (changedFields.emailChanged) { + clientData.emailOldOtpId = this.oldEmailOtpId; + clientData.emailNewOtpId = this.newEmailOtpId; + } + this.savingClient = true; this.clientsService.updateClient(this.clientId, clientData).subscribe({ next: () => { @@ -342,6 +456,14 @@ export class EditClientComponent { originalData.fullname = form?.get('fullname')?.value ?? originalData.fullname; } + // Notification is backend-driven (SMS + Email) — just reassure staff it happened. + if (changedFields.mobileChanged || changedFields.emailChanged) { + this.snackBar.open('Customer notified via SMS/Email of the contact change.', 'Close', { + duration: 4000 + }); + } + this.resetContactVerificationState(); + this.selectedTabIndex = this.selectedTabIndex + 1; }, error: () => { @@ -430,12 +552,14 @@ export class EditClientComponent { return; } - // Address tab + // Address tab — no OTP gate (an address has no "old/new channel" to verify an OTP + // against); notification-only, per the plan. Save proceeds normally. const addressTabIdx = this.legalFormType === 2 ? 3 : 2; if (tabIndex === addressTabIdx) { this.savingClient = true; if (this.clientIndiaAddressStep?.hasAddressData) { this.saveClientAddresses(this.clientId); + this.snackBar.open('Customer notified via SMS/Email of the address change.', 'Close', { duration: 4000 }); } this.savingClient = false; this.tabEditMode[tabIndex] = false; @@ -465,20 +589,41 @@ export class EditClientComponent { /** * Compare current form values with original data to detect changes in name, DOB, mobile, email. */ + /** "YYYY-MM-DD" built from a Date's LOCAL components — see the timezone-safety note where + * this is called from `detectSensitiveFieldChanges()`. */ + private toLocalDateString(date: Date): string { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; + } + private detectSensitiveFieldChanges(originalData: any) { const form = this.clientGeneralForm; - const newMobile = form?.get('mobileNo')?.value || ''; - const newEmail = form?.get('emailAddress')?.value || ''; + // Trimmed so browser autofill adding invisible leading/trailing whitespace never reads as + // a "change" — the masked preview shown in the Verify Contact Change panel only reveals the + // first couple of characters, so a whitespace-only difference is otherwise invisible to the + // user and just looks like the panel appeared for no reason. These trimmed-but-original-case + // values are what get returned/sent as the actual OTP destination and saved value — only the + // *comparison* (below) is further case-normalized for email, so we never silently rewrite the + // customer's email casing into the saved record or the OTP destination. + const newMobile = (form?.get('mobileNo')?.value || '').trim(); + const newEmail = (form?.get('emailAddress')?.value || '').trim(); const newDobRaw = form?.get('dateOfBirth')?.value; - const newDob = newDobRaw instanceof Date ? newDobRaw.toISOString().split('T')[0] : newDobRaw || ''; + // Local calendar date, NOT `.toISOString()` — that converts to UTC first, which silently + // shifts the date by a day in any positive-UTC-offset timezone (e.g. India, UTC+5:30: + // midnight local on the 15th becomes 18:30 UTC on the 14th). That made this comparison + // report a DOB "change" — falsely triggering the Re-KYC dialog — even when the user never + // touched the date field, purely because the Date object round-tripped through the picker. + const newDob = newDobRaw instanceof Date ? this.toLocalDateString(newDobRaw) : newDobRaw || ''; - const oldMobile = originalData.mobileNo || ''; - const oldEmail = originalData.emailAddress || ''; + const oldMobile = (originalData.mobileNo || '').trim(); + const oldEmail = (originalData.emailAddress || '').trim(); const oldDobRaw = originalData.dateOfBirth; const oldDob = Array.isArray(oldDobRaw) ? `${oldDobRaw[0]}-${String(oldDobRaw[1]).padStart(2, '0')}-${String(oldDobRaw[2]).padStart(2, '0')}` : oldDobRaw instanceof Date - ? oldDobRaw.toISOString().split('T')[0] + ? this.toLocalDateString(oldDobRaw) : oldDobRaw || ''; const buildName = (parts: { firstname?: string; middlename?: string; lastname?: string; fullname?: string }) => @@ -509,7 +654,9 @@ export class EditClientComponent { return { nameChanged: newName !== oldName, mobileChanged: newMobile !== oldMobile, - emailChanged: newEmail !== oldEmail, + // Case-insensitive: emails are not case-sensitive in practice, and comparing raw case + // would flag a change (surfacing the dual-OTP panel) even when nothing meaningful changed. + emailChanged: newEmail.toLowerCase() !== oldEmail.toLowerCase(), dobChanged: newDob !== oldDob, oldName, newName, diff --git a/src/app/shared/icons.module.ts b/src/app/shared/icons.module.ts index 1bcd63497..fa3deecb1 100644 --- a/src/app/shared/icons.module.ts +++ b/src/app/shared/icons.module.ts @@ -166,6 +166,7 @@ import { faClipboardList, faMap, faCamera, + faVideo, faSlidersH, faPrint, faCertificate, @@ -401,6 +402,7 @@ export class IconsModule { faClipboardList, faMap, faCamera, + faVideo, faSlidersH, faPrint, faCertificate, diff --git a/src/app/shared/utils/data-url.util.ts b/src/app/shared/utils/data-url.util.ts new file mode 100644 index 000000000..869dfd2bd --- /dev/null +++ b/src/app/shared/utils/data-url.util.ts @@ -0,0 +1,36 @@ +/** + * 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/. + */ + +/** + * Converts a `data:` URL (e.g. produced by `HTMLCanvasElement.toDataURL()` in the + * webcam-capture dialog) into a Blob, preserving the embedded MIME type. + */ +export function dataUrlToBlob(dataUrl: string): Blob { + const [ + header, + base64 + ] = dataUrl.split(','); + const mimeMatch = /data:(.*?);base64/.exec(header || ''); + const mime = mimeMatch ? mimeMatch[1] : 'image/jpeg'; + const binary = atob(base64 || ''); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return new Blob([bytes], { type: mime }); +} + +/** + * Converts a `data:` URL into a `File` so it can be routed through the same + * upload/crop pipelines used for file-picker input (multipart form posts, the + * image-crop dialog, etc). + */ +export function dataUrlToFile(dataUrl: string, filename: string): File { + const blob = dataUrlToBlob(dataUrl); + return new File([blob], filename, { type: blob.type || 'image/jpeg' }); +}