import { useState } from 'react'; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '../ui/dialog'; import { Checkbox } from '../ui/checkbox'; import { Label } from '../ui/label'; import { Input } from '../ui/input'; import { HttpError } from '../../services/HttpService.tsx'; import { useStaffSmsVerificationService } from '../../services/CommunicationPreferenceService.tsx'; // --------------------------------------------------------------------------------------------- // S2b, Task 8 - THE IN-OFFICE PATH. A portal-only verification gate excludes exactly the // beta cohort: seniors and tech-averse patients who never open the portal (the in-office-consent- // capture rule). This is how staff help them, with the patient physically present. The patient reads // the code off their own handset and gives it to the receptionist -- staff never see the code // themselves; it exists nowhere in either server response (StaffSmsVerification{Request,Verify}Response // carry only pendingVerificationId/maskedNumber and verified/verifiedAt). // // Two steps in ONE dialog, mirroring PatientSettingsPage's own request/code-entry flow ( S2b, // Task 7) but gated behind a mandatory attestation the patient side never needed (the patient IS the // one consenting there; here somebody ELSE is acting for them, so the server requires -- and this // dialog enforces client-side too -- a witnessed claim that the patient actually agreed): // 1. Attestation -- a FIXED statement of exactly what is being affirmed (STAFF_SMS_ATTESTATION_TEXT), // sent verbatim as attestation.text and written into the server's evidentiary audit record. NOT // pre-checked; "Send code" stays disabled until it is affirmed, mirroring the server's G7 gate // (handlers/StaffSmsVerification/shared.ts::parseStaffAttestation) so an unaffirmed request is // never even attempted. // 2. Code entry -- the masked number is rendered exactly as the server returns it (never // reconstructed from digits already on the patient); staff type in what the patient read aloud. // // No Cancel button anywhere in this dialog -- the built-in X (DialogContent's default close button) // cancels, the same convention CommunicationConsentDialog uses for the patient-side consent gate. // --------------------------------------------------------------------------------------------- // THIS STRING IS EVIDENCE, NOT COPY. It is transmitted verbatim as `attestation.text` and embedded // in the AuditEvent description the server writes (StaffSmsVerification/request.ts), so it becomes part // of a permanent, patient-visible record of a witnessed act. It must therefore assert ONLY what the // staff member actually observed. // // IT USED TO END "Once verified, appointment reminders will be sent to this number by text message." // That is FALSE. Per the server's eligibleChannels, texting additionally requires the patient's own // `sms` and `appointmentReminders` consents, and R5 makes enabling them a distinct act that only the // patient can perform - nothing here gates on them. A witnessed statement that misdescribes its own // consequence is precisely what collapses under challenge, and it was an authority-expansion // attestation to boot. The texting outcome is now explained in the UI (where it can be conditioned on // the patient's real state) and asserted nowhere. export const STAFF_SMS_ATTESTATION_TEXT = 'I confirm this patient is here with me and has given explicit permission to verify that this ' + 'mobile number belongs to them.'; // Every failure either endpoint can return maps to its own copy, never the server's raw message. // 400 is deliberately opaque on BOTH calls (a wrong/expired/burnt code and a record-state refusal are // indistinguishable by server design) but worded per-step so a request-phase refusal never talks about // "a code" that was never entered. 503 must never read like a wrong code -- the server refuses to flip // the gate when it cannot durably audit why (provider-transient OR the audit queue is unavailable), and // treating that as "your code was wrong" would send staff into a pointless retry loop on a code that // was never bad. 429 gets a rate message with no suggestion of an instant retry. 403 (no patient-side // equivalent) means this staff member has no business with this patient record at all. // // 409 IS THE ONE TERMINAL FAILURE, and it is the only status that must not leave the code entry // open. verifyFlow.ts DELETES the pending record when the patient's selected number changed after the // code was sent, so the pendingVerificationId held in state is dead: every subsequent Confirm answers // the opaque 400 forever. See `isTerminal` and confirmCode below -- the message alone is not the fix. const SMS_NUMBER_CHANGED = 'The selected number changed. Start again to send a code to the new number.'; const describeStaffSmsError = (err: unknown, context: 'request' | 'verify'): string => { if (err instanceof HttpError) { if (err.status === 403) return "You do not have permission to verify this patient's number."; if (err.status === 409) return SMS_NUMBER_CHANGED; if (err.status === 429) return 'Too many attempts. Try again later.'; if (err.status === 503) return 'Verification is temporarily unavailable. Please try again shortly.'; if (err.status === 502) return 'Could not send the verification code. Try again later.'; if (err.status === 400) { return context === 'verify' ? 'That code is not valid or has expired. Please request a new one.' : 'Could not start verification for this number. Please try again.'; } } return context === 'verify' ? 'That code could not be verified. Please try again.' : 'Could not start verification. Please try again.'; }; /** 409 alone: the server has destroyed the record, so retrying the held code can never succeed. */ const isNumberChanged = (err: unknown): boolean => err instanceof HttpError && err.status === 409; export interface StaffSmsVerificationDialogProps { open: boolean; /** Named explicitly in the body on every call -- staff act for someone else, there is no "caller's own patient". */ patientId: string; /** * Whether the patient has ALREADY given the consents that let a verified number be texted * (config/communicationPreferences.ts::isSmsDeliveryConsented). When false, this dialog says so -- * it never offers a way to change it. R5: that is the patient's own act. */ smsDeliveryConsented: boolean; onClose: () => void; onVerified: (verifiedAt: string) => void; } export function StaffSmsVerificationDialog({ open, patientId, smsDeliveryConsented, onClose, onVerified }: StaffSmsVerificationDialogProps) { const { requestStaffSmsVerification, verifyStaffSmsCode } = useStaffSmsVerificationService(); const [affirmed, setAffirmed] = useState(false); const [requesting, setRequesting] = useState(false); const [pending, setPending] = useState<{ pendingVerificationId: string; maskedNumber: string } | null>(null); const [code, setCode] = useState(''); const [verifying, setVerifying] = useState(false); const [error, setError] = useState(null); const reset = () => { setAffirmed(false); setRequesting(false); setPending(null); setCode(''); setVerifying(false); setError(null); }; const handleOpenChange = (next: boolean) => { if (!next) { reset(); onClose(); } }; const sendCode = async () => { if (!affirmed) return; // Mirrors the server's G7 gate -- never even attempted unaffirmed. setRequesting(true); setError(null); try { const res = await requestStaffSmsVerification(patientId, STAFF_SMS_ATTESTATION_TEXT); // Only these two fields are ever read off the response -- nothing else the server might // (mistakenly) include is surfaced. The code is never part of this response shape. setPending({ pendingVerificationId: res.pendingVerificationId, maskedNumber: res.maskedNumber }); setCode(''); } catch (e) { setError(describeStaffSmsError(e, 'request')); } finally { setRequesting(false); } }; const confirmCode = async () => { if (!pending || code.trim().length === 0) return; setVerifying(true); setError(null); try { const res = await verifyStaffSmsCode(patientId, pending.pendingVerificationId, code.trim()); const verifiedAt = res.verifiedAt; reset(); onVerified(verifiedAt); onClose(); } catch (e) { if (isNumberChanged(e)) { // 409 -- the server deleted the record, so this code entry is dead. Go back to step 1 // and drop the attestation: it was witnessed about a DIFFERENT number, and re-using it // for the new one would be exactly the kind of carried-over claim the attestation // exists to prevent. Staff affirm again for the number that is actually selected now. setPending(null); setCode(''); setAffirmed(false); } // Otherwise leave the code entry usable -- a wrong/expired code (or a transient/rate // failure) should let staff try again without re-requesting a fresh code. setError(describeStaffSmsError(e, 'verify')); } finally { setVerifying(false); } }; return ( Verify SMS number For staff use with the patient present in office. {!pending ? ( <>
setAffirmed(checked === true)} disabled={requesting} />
{/* * The consequence the attestation deliberately no longer claims, stated here * where it can be conditioned on the patient's ACTUAL consent state. Staff need * to know that finishing this does not, on its own, start texts -- otherwise * they tell the patient it will. * * 🚨 INFORMATIONAL ONLY. There is no control here to switch the channel on, and * there must never be one: R5 makes that the patient's affirmative act, and a * receptionist manufacturing a texting consent is the exact thing this whole * flow is built to keep impossible. */} {!smsDeliveryConsented && (

Verifying this number will not start text messages. The patient must also turn on “Text (SMS) messages” and “Appointment reminders” themselves, in their own account settings.

)} {error &&

{error}

}
) : ( <>

Code sent to {pending.maskedNumber}. Ask the patient to read it to you.

{ setCode(e.target.value); setError(null); }} disabled={verifying || requesting} className="w-32" aria-label="Verification code" /> {/* * 🔴 CLOSES THE STAFF SIDE'S OWN DEAD END, mirroring the patient surface's * "Send a new code" (PatientSettingsPage.tsx). On a 400 the copy above says * "Please request a new one" and, before this, there was no control on * screen to do that -- only the dialog's X, which drops the whole attempt. * * Calls the SAME `sendCode` the initial "Send code" step used, which is * exactly why this must NOT reset `affirmed` or re-show the checkbox: the * attestation staff already gave (STAFF_SMS_ATTESTATION_TEXT, a fixed * string, never re-collected here) still describes this patient and this * number -- unlike the 409 path below, which clears it because the number * itself changed. Same endpoint, same resend-vs-replace decision made * server-side; its 429 maps through describeStaffSmsError exactly as the * initial request's does. */}
{error &&

{error}

} )}
); } export default StaffSmsVerificationDialog;