import { useState, useEffect, useCallback, useRef } from 'react'; import { Spinner } from "../ui/spinner"; import { useNavigate } from 'react-router-dom'; import { InputControl } from '../ui/input-control'; import { DismissibleAlert } from '../ui/dismissible-alert'; import { Modal } from '../ui/wp-modal-shim'; import { useCapabilityServiceContext } from '../../services/CapabilityService'; import type { Organization } from 'fhir/r4'; import { useAuthServiceContext } from '../../services'; import { useDuplicateMatchServiceContext, DuplicateMatchSummary, DuplicateMatchConfirmResponse } from '../../services/DuplicateMatchService.tsx'; import './DuplicateMatchPrompt.css'; import { relayServerFailure } from '../../utils/fetchFailureMessage'; type Step = 'choose' | 'otp' | 'resume' | 'done'; type ModalCommand = { readonly opening: object; readonly attempt: object; readonly match: Readonly }; // The organization whose portal the patient is currently signed in through (the WP entry // context). A duplicate match is patient-to-patient and cross-org by nature, so no single // org is carried on the match itself - this is the current-context org, used only to give // the patient a recognizable point of reference for the notice, not derived from the match. const organizationWebsite = (org: Organization | null): string | undefined => { const raw = org?.telecom?.find((point) => point.system === 'url' && !!point.value)?.value; if (!raw) return undefined; // Only allow http(s) into the href - reject javascript:/data:/vbscript: and malformed // URLs so a hostile Organization.telecom value can't become an XSS sink. try { const url = new URL(raw); return url.protocol === 'https:' || url.protocol === 'http:' ? url.toString() : undefined; } catch { return undefined; } }; // These DuplicateMatch endpoints return curated, user-safe messages (no enumeration // or internal detail), so surfacing the server text is intentional and scoped to this // component - do NOT generalize this to endpoints with uncurated error bodies. const serverMessage = (err: unknown): string | undefined => { const raw = relayServerFailure(err, '').trim(); return raw.length > 0 ? raw : undefined; }; // matchedOn is the caller's OWN matched field categories (server labels like // "phone number", "name and date of birth", "name and address") - never the other // account's data. Used only to describe what matched and steer the choice. const formatMatchedList = (items: string[]): string => { if (items.length === 0) { return ''; } if (items.length === 1) { return items[0]; } if (items.length === 2) { return `${items[0]} and ${items[1]}`; } return `${items.slice(0, -1).join(', ')}, and ${items[items.length - 1]}`; }; // One guidance sentence by strongest signal (phone > address > name/DOB). Substring // match keeps it resilient to minor label wording; the name/DOB line is the default. const matchGuidance = (items: string[]): string => { const has = (needle: string) => items.some((label) => label.toLowerCase().includes(needle)); if (has('phone')) { return 'This usually means you registered more than once, or someone in your household used your phone number.'; } if (has('address')) { return 'This usually means you registered more than once, or someone at your address has an account.'; } return 'This could be you from a previous registration — or a different person who happens to share your name and date of birth.'; }; const DuplicateMatchPrompt = () => { const { currentUser, currentOrganization } = useAuthServiceContext(); const { fetchOpenMatches, requestChallenge, confirmMatch, dismissMatch } = useDuplicateMatchServiceContext(); const { mayCall } = useCapabilityServiceContext(); const navigate = useNavigate(); const [matches, setMatches] = useState([]); const [activeMatch, setActiveMatch] = useState(null); const [step, setStep] = useState('choose'); const [maskedEmail, setMaskedEmail] = useState(''); const [code, setCode] = useState(''); const [error, setError] = useState(); const [isBusy, setIsBusy] = useState(false); const [doneMessage, setDoneMessage] = useState(''); // Offered only where there is somewhere useful to go. The verification path ends at a finished // action and needs no door; the household path ends at something the patient may want to act on. const [offerFamilyLink, setOfferFamilyLink] = useState(false); // Each Review is a new opening, even for the same match. A command keeps its original match, // but only this opening's latest attempt may publish an answer, refusal or busy-state cleanup. const mounted = useRef(true); const opening = useRef(null); const attempt = useRef(null); const renderedOpening = opening.current; useEffect(() => { mounted.current = true; return () => { mounted.current = false; opening.current = null; attempt.current = null; }; }, []); const ownsPublication = (command: ModalCommand) => mounted.current && opening.current === command.opening && attempt.current === command.attempt; const beginCommand = (): ModalCommand | undefined => { if (!activeMatch || !renderedOpening || opening.current !== renderedOpening || !mounted.current) return; const command = { opening: renderedOpening, attempt: {}, match: { ...activeMatch } }; attempt.current = command.attempt; return command; }; // Staff can have matches, but Family & caregivers is a patient-only destination. const resourceType = currentUser?.fhir?.resourceType; const isPatient = resourceType === 'Patient'; // Identity is not capability: a Billing-only hat reads as a Practitioner and was refused this route // on every page load. `??` keeps the old rule while capability is unknown. const canHaveMatches = mayCall('GET', '/DuplicateMatch') ?? (isPatient || resourceType === 'Practitioner'); useEffect(() => { if (!canHaveMatches) { return; } fetchOpenMatches().then(setMatches).catch((err) => { console.error('Failed to fetch duplicate matches:', err); }); }, [canHaveMatches, fetchOpenMatches]); const closeModal = useCallback(() => { opening.current = null; attempt.current = null; setActiveMatch(null); setStep('choose'); setMaskedEmail(''); setCode(''); setError(undefined); setDoneMessage(''); setOfferFamilyLink(false); setIsBusy(false); }, []); const handleReview = (match: DuplicateMatchSummary) => { opening.current = {}; attempt.current = null; setIsBusy(false); setActiveMatch(match); setStep(match.continuation ? 'resume' : 'choose'); setCode(''); setMaskedEmail(''); setDoneMessage(''); setOfferFamilyLink(false); setError(undefined); }; const handleThisIsMe = async () => { const command = beginCommand(); if (!command) return; setIsBusy(true); setError(undefined); try { const { maskedEmail: email } = await requestChallenge(command.match.matchId); if (!ownsPublication(command)) return; setMaskedEmail(email); setCode(''); setStep('otp'); } catch (err) { if (!ownsPublication(command)) return; console.error('Failed to request duplicate match challenge:', err); setError('Could not send a verification code. Try again later.'); } finally { if (ownsPublication(command)) setIsBusy(false); } }; const applyOriginalResult = (command: ModalCommand, result: DuplicateMatchConfirmResponse) => { if (!ownsPublication(command)) return; const original = command.match; const unfinished = result.continuation && result.continuation !== 'complete'; const message = result.status === 'linked' ? 'Your accounts are linked.' + (result.reLogin ? ` Please sign in with ${result.survivorMaskedEmail} to continue.` : '') + (result.duplicateDisablePending ? ' Closing the duplicate sign-in is still pending.' : '') + (unfinished ? ' Some follow-up work remains.' : '') : 'Your account link is not yet confirmed. You can continue your original request.'; setDoneMessage(message); if (unfinished) { const continuation = result.continuation as DuplicateMatchSummary['continuation']; const linkStatus = result.status === 'linked' ? 'linked' as const : 'pending' as const; setActiveMatch({ ...original, continuation, linkStatus, supportRequired: result.supportRequired }); setMatches(previous => ownsPublication(command) ? previous.map(match => match.matchId === original.matchId ? { ...match, continuation, linkStatus, supportRequired: result.supportRequired } : match) : previous); setStep('resume'); } else { setMatches(previous => ownsPublication(command) ? previous.filter(match => match.matchId !== original.matchId) : previous); setStep('done'); } }; const handleResume = async () => { const command = beginCommand(); if (!command) return; setIsBusy(true); setError(undefined); try { applyOriginalResult(command, await confirmMatch(command.match.matchId, code || undefined)); } catch (err) { if (!ownsPublication(command)) return; setError(serverMessage(err) ?? 'Could not continue your original request. Try again later.'); if ((err as { status?: number })?.status === 401 && command.match.linkStatus !== 'linked') { setActiveMatch({ ...command.match, continuation: 'verification-required' }); } } finally { if (ownsPublication(command)) setIsBusy(false); } }; const handleVerify = async () => { const command = beginCommand(); if (!command) return; setIsBusy(true); setError(undefined); try { const result = await confirmMatch(command.match.matchId, code); applyOriginalResult(command, result); } catch (err) { if (!ownsPublication(command)) return; console.error('Failed to confirm duplicate match:', err); setError(serverMessage(err) ?? 'Could not verify that code. Try again, or contact support if this continues.'); } finally { if (ownsPublication(command)) setIsBusy(false); } }; const handleResend = async () => { const command = beginCommand(); if (!command) return; setIsBusy(true); setError(undefined); try { const { maskedEmail: email } = await requestChallenge(command.match.matchId); if (!ownsPublication(command)) return; setMaskedEmail(email); setCode(''); } catch (err) { if (!ownsPublication(command)) return; console.error('Failed to resend duplicate match challenge:', err); setError(serverMessage(err) ?? 'Could not resend the code. Try again later.'); } finally { if (ownsPublication(command)) setIsBusy(false); } }; /* * IT SAYS WHAT IT CAN AND CANNOT DO, INSTEAD OF LANDING SOMEBODY ON AN EMPTY PAGE. * * This used to close the dialog and navigate straight to Family & caregivers. Walked: the patient * arrived at their ordinary list of dependents with NOTHING carried across - no reference to the * match, no prefilled invite, not a word about the decision they had just made - and the notice was * gone from the home screen afterwards. The question was recorded as answered and no relationship * existed. * * AND THEY CANNOT FINISH IT THEMSELVES, WHICH IS WHAT MAKES THE SILENCE WORSE THAN UNTIDY. Inviting * somebody from that page needs their name, relationship and email - and this dialog deliberately, * correctly, told the patient NOTHING about the other account. The privacy rule that makes the * question safe to ask is exactly what makes this branch impossible to complete unaided. * * CONNECTING THE TWO RECORDS IS NOT BUILT - there is no relate endpoint; the server records * `dismissed-family` and stops. So this does not pretend. It says the accounts stay separate, says * why we cannot name the other one, and offers the page as a CHOICE for the common case where the * patient already knows who in their household shares the phone number. */ const handleFamily = async () => { if (!activeMatch) { return; } setIsBusy(true); setError(undefined); try { await dismissMatch(activeMatch.matchId, 'family'); setDoneMessage( 'Thanks - we have recorded that this is somebody else in your household, and the two ' + 'accounts stay separate. We cannot tell you which account it was, so we cannot link ' + 'them for you.' + (isPatient ? ' If you know who in your household shares your details, you can invite them from Family & caregivers.' : ''), ); setOfferFamilyLink(isPatient); setStep('done'); setIsBusy(false); } catch (err) { console.error('Failed to dismiss duplicate match as family:', err); setError('Could not record your answer about this possible match. Try again.'); setIsBusy(false); } }; const handleNotMe = async () => { if (!activeMatch) { return; } setIsBusy(true); setError(undefined); try { await dismissMatch(activeMatch.matchId, 'not-me'); setMatches((prev) => prev.filter((match) => match.matchId !== activeMatch.matchId)); closeModal(); } catch (err) { console.error('Failed to dismiss duplicate match:', err); setError('Could not record your answer about this possible match. Try again.'); setIsBusy(false); } }; // Keep the component mounted while a modal is open even if its match was the last // one removed from the list - otherwise the success "done" screen unmounts on the // same render that clears the resolved match, and the user never sees it. if (!canHaveMatches || (matches.length === 0 && !activeMatch)) { return null; } const renderChooseStep = () => { const matched = activeMatch?.matchedOn ?? []; const matchedList = formatMatchedList(matched); const orgName = currentOrganization?.name; const orgWebsite = organizationWebsite(currentOrganization); return (
{error && ( {error} )}

{matchedList ? ( <>We found another account that shares your {matchedList}. ) : ( 'We found another account with information similar to yours.' )}

{matchGuidance(matched)}

{orgName && (

You're viewing this notice in the{' '} {orgWebsite ? ( {orgName} ) : ( {orgName} )}{' '} portal.

)} {/* THE SAME VARIANT ON EVERY ANSWER, and the sameness is the ruling rather than the colour. These are answers to "is this you?", and a colour difference between them is the product recommending one - which on an identity decision is a clinical position taken in paint. Both outcomes are expensive and neither is safe: a wrong join shows one person another person's record, and a wrong split makes a duplicate only the patient can merge back, by a code, because staff never can. This row used to say the colour "tracks emphasis of the choice (primary / secondary / dismissive)". That was the recommendation, written down. ⚠️ The ruling names the PAIR - Not me / This is me. "A family member" is a third answer to the same question and takes the same weight for the same reason; if that reads as over-reach, it is the line to argue with. Busy is still the LABEL swapping for a Spinner, with btn-busy alongside, so nothing visible changes while it works. */}

Choosing “This is me” sends a verification code to the email on the other account to confirm it's yours.

); }; const renderOtpStep = () => (
{error && ( {error} )}

We sent a 6-digit code to {maskedEmail} — the email on the other account.

setCode(value ?? '')} />

Can't access this email? Contact support.

); const renderDoneStep = () => (
{doneMessage}
{offerFamilyLink && isPatient && ( )} {/* Secondary to the route beside it, so it takes that pair's ghost. "Close" is the right WORD - this step reports an outcome that is already recorded, so closing decides nothing and a verb would invent a decision the reader is not being asked to make. Only the neutral grey was wrong. */}
); const renderModalContent = () => { switch (step) { case 'resume': return
{error && {error}}

{doneMessage || (activeMatch?.linkStatus === 'linked' ? 'Your accounts are linked. Some follow-up work remains.' : 'Your original account-link request is unfinished.')}

{activeMatch?.supportRequired && activeMatch.continuation !== 'support-required' &&

One earlier action needs support. You can continue the remaining work on your original request.

} {activeMatch?.continuation === 'support-required' ?

A previous action has an unconfirmed result. Contact support before trying again.

: }
; case 'otp': return renderOtpStep(); case 'done': return renderDoneStep(); case 'choose': default: return renderChooseStep(); } }; return ( <> {matches.map((match) => ( {/* The row wrapper is new: DismissibleAlert puts its children in an AlertDescription that is itself a grid, so the text and the Review button would stack instead of sitting side by side. `ahr-dup-match-banner__row` carries the flex layout that used to live on the banner element — see the note in the stylesheet for why it could not stay there. */} {match.continuation ? (match.linkStatus === 'linked' ? 'Your linked accounts have unfinished follow-up work.' : 'Your original account-link request is unfinished.') : <>We noticed another account with similar information (matched on: {match.matchedOn.join(', ')}).} {/* 🔵 CARD 240 / B59 — THIS IS A BLUE CONTROL, and it is the highest-consequence item on that card rather than another label fix. 📏 Measured on the patient home at 390x844: the notice rendered muted with "Review" as a link-shaped, padding-less button inside a borderless card — it READS AS DISABLED HELPER TEXT, and a patient scanning a phone skips it. 🚨 It is the one prompt in the product that asks a patient to resolve a possible duplicate of THEMSELVES. Matching is global, so an ignored prompt is a fork in a patient's longitudinal record — the exact thing this product exists to prevent. 🔗 Two rules land on it at once: an action that OPENS something takes the top-level blue, and an action that does not look like a control is not reachable as one — which is the accessibility half of the same finding. ⛔ `btn-stripped` was not wrong as a house style; it was the wrong ROLE. */} ))} {activeMatch && ( {renderModalContent()} )} ); }; export default DuplicateMatchPrompt;