import React, { useEffect, useRef, useState } from 'react'; import { useDirtyReport } from '../../../../hooks/useDirtyReport'; import { CheckboxControl } from "../../../../components/ui/checkbox-control"; import { TextControl } from "../../../../components/ui/text-control"; import { DismissibleAlert } from '../../../../components/ui/dismissible-alert'; import { Flex, FlexItem } from '../../../../components/ui/flex'; import { Spacer } from '../../../../components/ui/spacer'; import { useAuthServiceContext } from '../../../../services/AuthService'; import { BaaRecord, PublishedBaaDocument, SignBaaRequest, useBAAService, BAA_SIGNING_ATTESTATION, } from '../../../../services/BAAService.tsx'; import { AgreementBlocks } from '../../../../components/FirstSignInGate/AgreementBlocks'; import { formatSingleAddress } from '../../../../utils/fhirAddress'; import { formatPhoneNumber } from '../../../../utils/phoneNumber'; import { formatFhirHumanName } from '../../../../utils/fhirHumanName.tsx'; import { baaSignErrorMessage, isAgreementChanged } from './baaSignErrors'; import { isBaaFormDirty } from './baaSignDraft'; import { isSaveRetired, startSaveOperation } from '../../../../utils/saveOperation'; import { onViewerChange } from '../../../../utils/writeThroughHold'; import { environment } from '../../../../environments/environment.ts'; import { CHECK_UNAVAILABLE, EARLIER_UNCONFIRMED, NOT_TRACKED, NOT_YET_CONFIRMED, SIGN_AGAIN_WARNING, SIGN_UNCONFIRMED, intentDigest, observeBaa, onHeldAttemptsChange, readHeldAttempts, receiptStanding, reserveAttempt, settleAttempt, signReceiptOf, updateAttempt, type BaaObservation, type HeldRead, type SignProof, type SignScope, } from './baaSignOutcome'; import './BaaSignModal.css'; export interface BaaSignFormParts { body: React.ReactNode; actions: React.ReactNode; // The agreement shown in full wants a wider frame. wide: boolean; } interface BaaSignFormProps { pdfTemplateUrl?: string; htmlTemplateUrl?: string; /** The published agreement in force. When set it is the text shown and the version signed. */ published?: PublishedBaaDocument | null; /** `proof` says whether this attempt's own answer is known, or only what the agreement now shows. */ onSigned: (record: BaaRecord, proof?: SignProof) => void; onDirtyChange?: (dirty: boolean) => void; // The Sign Akashic BAA modal and the setup guide's BAA panel frame the same form differently. children: (parts: BaaSignFormParts) => React.ReactNode; } const escapeHtml = (value: string): string => value .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); // Tokens expected in the S3 HTML template (baa-templates/akashic-standard-baa.html). // The authoritative document is still the server-generated PDF; this HTML is a // presentation copy and must be kept in step with the PDF template in S3. const fillTemplate = (template: string, form: SignBaaRequest, signDate: string): string => template .replace(/\{\{ORG_NAME\}\}/g, escapeHtml(form.orgName)) .replace(/\{\{SIGNER_NAME\}\}/g, escapeHtml(form.signerName)) .replace(/\{\{SIGNER_TITLE\}\}/g, escapeHtml(form.signerTitle)) .replace(/\{\{ORG_ADDRESS\}\}/g, escapeHtml(form.orgAddress)) .replace(/\{\{ORG_PHONE\}\}/g, escapeHtml(form.orgPhone)) .replace(/\{\{SIGN_DATE\}\}/g, escapeHtml(signDate)); // The deck shows the agreement as compact reading text; the template's own heading sizes are for the PDF. // Placed last in the head so it wins over the template's own rules. const COMPACT_DOC_STYLE = ''; const withCompactStyle = (html: string): string => /<\/head>/i.test(html) ? html.replace(/<\/head>/i, (end) => COMPACT_DOC_STYLE + end) : html + COMPACT_DOC_STYLE; // Every field is printed into the executed agreement, so none may be blank; the labels name what is missing. const REQUIRED_FIELDS: ReadonlyArray<[keyof SignBaaRequest, string]> = [ ['signerName', 'Who is signing'], ['signerTitle', 'Your title or role'], ['orgName', 'Organization legal name'], ['orgAddress', 'Organization address'], ['orgPhone', 'Organization phone'], ]; // The published agreement, through the same block renderer the sign-in gate uses for Akashic's own // agreements, so a numbered list keeps its numbers and a link stays a link here too. const PublishedAgreement = ({ agreement }: { agreement: PublishedBaaDocument }) => (
); // A refusal the route answered before writing anything: it settles only the attempt it answered. const refusedBeforeWrite = (error: unknown): boolean => { const status = (error as { status?: unknown } | null)?.status; return typeof status === 'number' && status >= 400 && status < 500; }; const signedNote = (observation: Extract): string => { const signedBy = observation.record.signedBy as { name?: unknown; title?: unknown } | undefined; const name = typeof signedBy?.name === 'string' && signedBy.name ? signedBy.name : undefined; const title = typeof signedBy?.title === 'string' && signedBy.title ? signedBy.title : undefined; const edition = observation.edition ? ` (version ${observation.edition})` : '; its version could not be confirmed'; return `This practice's agreement is signed${edition}${name ? ` by ${name}${title ? `, ${title}` : ''}` : ''}.`; }; const BaaSignForm = ({ pdfTemplateUrl, htmlTemplateUrl, published, onSigned, onDirtyChange, children }: BaaSignFormProps) => { const { wpOrganization, currentUser } = useAuthServiceContext(); const { signBaa, getBaa } = useBAAService(); const viewer = currentUser?.fhir?.id ? `${currentUser.fhir.resourceType}/${currentUser.fhir.id}` : undefined; const practiceId = wpOrganization.current?.id; const scopeKey = practiceId && viewer ? JSON.stringify([practiceId, viewer, environment.fhirBaseApi]) : undefined; const scopeOf = (): SignScope | undefined => (scopeKey && practiceId && viewer ? { practiceId, viewer, apiOrigin: environment.fhirBaseApi } : undefined); const scopeNow = useRef(scopeOf); scopeNow.current = scopeOf; const heldIn = (scope: SignScope | undefined): HeldRead => (scope ? readHeldAttempts(scope) : { held: [], unreadable: false }); const [held, setHeld] = useState(() => heldIn(scopeOf())); const [checking, setChecking] = useState(false); const [checkNote, setCheckNote] = useState(null); // Signing again after an unconfirmed attempt is a new act: offered only once Check found nothing yet, // and only after the warning is acknowledged and the agreement confirmed again. const [mayResign, setMayResign] = useState(false); const [resignAcknowledged, setResignAcknowledged] = useState(false); const [isSaving, setIsSaving] = useState(false); const [error, setError] = useState(null); // Each opening is its own identity, set up by its effect: an answer commits only into the opening it // started in. Another viewer or practice is a new opening with its own state, while every attempt still // kept in this tab stays held for it to Check. const opening = useRef(null); const reopen = () => { const fresh = {}; opening.current = fresh; setIsSaving(false); setChecking(false); setCheckNote(null); setError(null); setMayResign(false); setResignAcknowledged(false); setHeld(heldIn(scopeNow.current())); return fresh; }; useEffect(() => { reopen(); const stopViewer = onViewerChange(reopen); const stopHeld = onHeldAttemptsChange(() => setHeld(heldIn(scopeNow.current()))); // A cleanup always comes before the next setup, so whatever opening is current ends here. return () => { stopViewer(); stopHeld(); opening.current = null; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [scopeKey]); const isHeld = held.unreadable || held.held.length > 0; const signHeld = isHeld && !(mayResign && resignAcknowledged); const orgPhone = formatPhoneNumber(wpOrganization.current?.telecom?.find((t) => t.system === 'phone')?.value ?? ''); const [initialForm] = useState(() => ({ // The person signing is usually the one signed in; the field stays editable. signerName: currentUser?.fhir?.name?.[0] ? formatFhirHumanName(currentUser.fhir.name[0]) : '', signerTitle: '', orgName: wpOrganization.current?.name ?? '', orgAddress: wpOrganization.current?.address?.[0] ? formatSingleAddress(wpOrganization.current.address[0]) : '', orgPhone, })); const [form, setForm] = useState(initialForm); // The sheet focuses its first field with the text selected; on that first focus the caret goes to the // end, so a keystroke does not replace the prefilled signer. Later selections are the person's own. const signerFirstFocus = useRef(true); const keepSignerName = (event: React.FocusEvent) => { if (!signerFirstFocus.current) return; signerFirstFocus.current = false; const input = event.currentTarget; setTimeout(() => input.setSelectionRange(input.value.length, input.value.length)); }; const [templateHtml, setTemplateHtml] = useState(null); // What the signer reads and signs. Replaced when the server says a newer version is in force. const [agreement, setAgreement] = useState(published ?? null); useEffect(() => { setAgreement(published ?? null); }, [published]); const [checkedRead, setCheckedRead] = useState(false); const [checkedBind, setCheckedBind] = useState(false); const dirty = isBaaFormDirty(form, initialForm, checkedRead || checkedBind); const missing = REQUIRED_FIELDS.filter(([field]) => !String(form[field] ?? '').trim()).map(([, label]) => label); useDirtyReport(dirty, onDirtyChange); useEffect(() => { // The S3 template is the agreement only while nothing is published. if (!htmlTemplateUrl || agreement) return undefined; let active = true; fetch(htmlTemplateUrl) .then((response) => { if (!response.ok) throw new Error(`Template fetch failed: ${response.status}`); return response.text(); }) .then((html) => { if (active) setTemplateHtml(html); }) .catch(() => { if (active) setTemplateHtml(null); }); return () => { active = false; }; }, [htmlTemplateUrl, agreement]); const handleSign = async () => { if (missing.length > 0) { setError(`To sign, fill in: ${missing.join(', ')}.`); return; } if (!checkedRead || !checkedBind) { setError('You must check both acknowledgment boxes before signing.'); return; } if (signHeld || isSaving) return; const scope = scopeOf(); const mine = opening.current; const owns = () => mine !== null && opening.current === mine && wpOrganization.current?.id === scope?.practiceId; const edition = agreement?.version ?? null; const kept = { v: 1 as const, edition, priorSignedAt: null, intentDigest: null, createdAt: new Date().toISOString() }; // Kept before anything is awaited: a reload while the signature is out still finds it. let own = scope ? reserveAttempt(scope, kept) : undefined; if (!scope || !own) { setError(NOT_TRACKED); return; } setIsSaving(true); setError(null); setCheckNote(null); // Owned while this opening, its practice and its viewer all still hold - checked after every wait. const operation = startSaveOperation(owns); const current = () => operation.belongs(); // Set once the request is handed to the network: one stopped before that was never sent. let sent = false; const transport = { ...operation.transport, onSend: () => { sent = true; } }; try { own = updateAttempt(own, { ...kept, intentDigest: await intentDigest([ scope.practiceId, edition, form.signerName, form.signerTitle, form.orgName, form.orgAddress, form.orgPhone, ]) }); const answer = await signBaa({ ...form, signingAttestation: { statementVersion: BAA_SIGNING_ATTESTATION.version }, ...(edition !== null ? { documentVersion: edition } : {}), }, { transport, organizationId: scope.practiceId }); const receipt = signReceiptOf(answer, scope.practiceId); if (!receipt) throw new Error(SIGN_UNCONFIRMED); settleAttempt(own); // The receipt is recorded server state; a read that fails or lags beside it must not undo it. const observation = observeBaa(await getBaa(operation.transport).catch(() => undefined), scope.practiceId); if (!current()) return; const standing = receiptStanding(receipt, observation); const record: BaaRecord = observation.kind === 'signed' && standing !== 'confirmation-pending' ? observation.record as unknown as BaaRecord : { ...receipt, type: 'default', signedBy: { name: form.signerName, title: form.signerTitle, orgName: form.orgName } }; onSigned(record, { proof: 'receipt', receipt, standing }); } catch (e: unknown) { // Never handed to the network, so it cannot have been recorded: its kept attempt goes. if (!sent) settleAttempt(own); if (!current() || isSaveRetired(e)) return; if (sent && !refusedBeforeWrite(e)) { // Nothing of ours answered, or not with a real receipt: the signature may have been recorded. setError(SIGN_UNCONFIRMED); setHeld(readHeldAttempts(scope)); setMayResign(false); setResignAcknowledged(false); return; } if (sent) settleAttempt(own); setHeld(readHeldAttempts(scope)); // THE REFUSALS THIS ROUTE CAN NOW RETURN ARE NOT FAILURES TO RETRY, and a bare // "Failed to sign BAA. Please try again." would send an admin round a loop that cannot end. // Read the server's `reason` CODE, never its prose: the copy is allowed to change, and a // client that matched on sentences would break silently the first time it did. setError(baaSignErrorMessage(e)); if (isAgreementChanged(e)) { // Show the agreement now in force. Having read different text, the signer confirms again. const now = await getBaa(operation.transport).catch(() => undefined); if (!current()) return; if (now) setAgreement(now.document ?? null); setCheckedRead(false); setCheckedBind(false); } } finally { if (current()) setIsSaving(false); } }; // Read-only: it never signs. What it finds is what the agreement is now, not what an earlier attempt did. const check = async () => { const scope = scopeOf(); if (!scope || checking) return; const mine = opening.current; const operation = startSaveOperation(() => mine !== null && opening.current === mine && wpOrganization.current?.id === scope.practiceId); setChecking(true); setCheckNote(null); const observation = observeBaa(await getBaa(operation.transport).catch(() => undefined), scope.practiceId); if (!operation.belongs()) return; setChecking(false); setHeld(readHeldAttempts(scope)); if (observation.kind === 'signed') { setCheckNote(signedNote(observation)); setMayResign(false); onSigned(observation.record as unknown as BaaRecord, { proof: 'observed-standing' }); return; } if (observation.kind === 'unsigned') { setCheckNote(NOT_YET_CONFIRMED); setMayResign(true); setResignAcknowledged(false); setCheckedRead(false); setCheckedBind(false); return; } setCheckNote(CHECK_UNAVAILABLE); }; const set = (field: keyof SignBaaRequest) => (value: string) => setForm((prev) => ({ ...prev, [field]: value })); // Display-only; the authoritative signedAt is stamped server-side on submit. // Same format the server writes into the PDF's date field. const signDate = new Date().toLocaleString('en-US', { month: '2-digit', day: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit', timeZone: 'UTC', timeZoneName: 'short', }); const filledHtml = !agreement && templateHtml !== null ? withCompactStyle(fillTemplate(templateHtml, form, signDate)) : null; const templateLink = pdfTemplateUrl && !agreement ? ( Business Associate Agreement ) : ( 'Business Associate Agreement' ); // No Cancel: both frames close from their own X. const actions = ( {missing.length > 0 && (

To sign, fill in: {missing.join(', ')}.

)}
); const body = (
{error && ( <> setError(null)}> {error} )} {isHeld && ( <>

{EARLIER_UNCONFIRMED}

{checkNote &&

{checkNote}

}
{mayResign && ( )}
)}

{agreement ? 'The agreement' : 'The agreement, with your details in it'}

{agreement ? ( ) : filledHtml !== null ? (