/** * 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; // The backend reports failure to actually dispatch the OTP (gateway error, flagged // number, SMTP failure...) as a 200 response with success:false and a real reason in // `message` — never a thrown error. Surface that reason instead of treating it as sent. if (res?.success === false) { this.errorMessage = res?.message || 'Could not send OTP.'; return; } 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); } }