import { useEffect, useRef, useState } from "react"; import { Printer, ShieldCheck } from "lucide-react"; import { useAuthServiceContext, useOrganizationServiceContext } from "../../../services"; import { useCapabilityServiceContext } from "../../../services/CapabilityService.tsx"; import { readOrganizationFaxSettings, type OrganizationFaxSettings } from "../../../utils/organizationFaxSettings"; import { Input } from "../../../components/ui/input.tsx"; import { Alert, AlertDescription } from "../../../components/ui/alert.tsx"; import { RadioGroup, RadioGroupItem } from "../../../components/ui/radio-group.tsx"; import { relayServerFailure } from "../../../utils/fetchFailureMessage"; import { isSaveRetired, TIMED_OUT_MAY_HAVE_SAVED, useSaveScope } from "../../../utils/saveOperation"; import { faxSetupHolder, holdFaxSetup, useUnresolvedFaxSetup, watchFaxWrite } from "./unresolvedFaxSetup"; import ReceiveFaxesSection from "./ReceiveFaxesSection"; import "./faxingSetup.css"; // - CHOOSE a fax vendor, then configure it. // // Spencer, 2026-08-22: *"the reason for 2 instead of 1 is so we already have the ui and settings in // place to select more than one making it easier to add additional vendors later."* // // SO THE CHOICE COMES FIRST, and that ordering IS the card. One vendor produces a "fax settings" // screen; two produce a "choose your fax vendor" screen - different designs, and the second cannot be // reached from the first without tearing it up. Adding a third vendor should add one entry to the list // below and nothing else. // // THE PRACTICE BRINGS THE ACCOUNT, and the screen says so out loud rather than leaving it implied. // It is why Akashic needs no fax-vendor BAA: we never transmit their PHI to a subcontractor of ours. // // ZERO WordPress: Tailwind + lucide + the shared ui primitives. const VENDORS = [ { id: "phaxio", name: "Phaxio", // Named because it decides the whole testing story: without a test mode, every trial send costs a // real transmission to a real number and needs a receiving line to observe. note: "Self-serve signup, and it has a test mode so you can try this without sending a real fax.", needsSecret: true, }, { id: "documo", name: "Documo (mFax)", note: "Self-serve signup. Uses a single API token.", needsSecret: false, }, { id: "sinch", name: "Sinch Fax", // NAMED SECOND-TO-PHAXIO ON PURPOSE: Phaxio's signup IS Sinch now, so a practice sent to Phaxio // today lands here and gets credentials the Phaxio option cannot use. Both are listed because a // practice with a genuine legacy Phaxio account still has one that works. note: "Where Phaxio signups now go. Uses a key ID, a key secret and the project ID from your Sinch dashboard.", needsSecret: true, needsProjectId: true, }, ] as const; const FaxSettingsPage = () => { const { updateFaxSettings } = useOrganizationServiceContext(); const { currentOrganization } = useAuthServiceContext(); // The server's own answer on who may save the fax account; only a yes shows the editor. const { state: capabilityState, mayCall } = useCapabilityServiceContext(); const mayConfigure = mayCall("PUT", "/fax-settings") === true; // The organization never carries the credentials themselves, only whether they are saved. const [stored, setStored] = useState(() => readOrganizationFaxSettings(currentOrganization)); const [vendorId, setVendorId] = useState(""); const [fromNumber, setFromNumber] = useState(""); const [apiKey, setApiKey] = useState(""); const [apiSecret, setApiSecret] = useState(""); const [projectId, setProjectId] = useState(""); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const [saved, setSaved] = useState(false); // Counts the accounts this editor has stored, or may have: receiving starts again with each one. const [accountGeneration, setAccountGeneration] = useState(0); const setupHolder = faxSetupHolder(currentOrganization?.id); const unresolved = useUnresolvedFaxSetup(setupHolder); // The editor belongs to one practice, viewer and right to configure. When any of them changes, or the page closes, // a save started before sends nothing more and shows nothing, and the drafts and notices go with it: the editor // starts again from the practice's own stored account, never from typed keys or a choice made for another. const shownOrganization = useRef(currentOrganization); shownOrganization.current = currentOrganization; const owner = `${currentOrganization?.id ?? ""}|${mayConfigure ? "configure" : "view"}`; const saveScope = useSaveScope(owner, () => { const own = readOrganizationFaxSettings(shownOrganization.current); setVendorId(own?.vendorId ?? ""); setFromNumber(own?.fromNumber ?? ""); setApiKey(""); setApiSecret(""); setProjectId(""); setBusy(false); setError(null); setSaved(false); }); useEffect(() => { setStored(readOrganizationFaxSettings(currentOrganization)); }, [currentOrganization]); // Typed keys never outlive the editor that took them. useEffect(() => { if (mayConfigure) return; setApiKey(""); setApiSecret(""); }, [mayConfigure]); useEffect(() => { if (!stored) return; setVendorId((current) => current || stored.vendorId); setFromNumber((current) => current || stored.fromNumber || ""); }, [stored]); const chosen = VENDORS.find((v) => v.id === vendorId); const storedVendor = VENDORS.find((v) => v.id === stored?.vendorId); // Every detail the chosen vendor uses, so a recovery save replaces the whole stored account. const complete = !!chosen && apiKey.trim() !== "" && (!chosen.needsSecret || apiSecret.trim() !== "") && (!("needsProjectId" in chosen && chosen.needsProjectId) || projectId.trim() !== ""); const saveAllowed = unresolved.account === "unconfirmed" ? false : unresolved.account === "recovering" ? complete : true; const save = async () => { const operation = saveScope.start(); const holder = setupHolder; const sent = { apiKey, apiSecret }; // Admitted again after the token and proof waits, so a save its owner has left is never sent for another. const write = watchFaxWrite((onSend) => updateFaxSettings({ vendorId, fromNumber: fromNumber.trim() || undefined, apiKey: apiKey.trim() || undefined, apiSecret: apiSecret.trim() || undefined, projectId: projectId.trim() || undefined, }, { ...operation.transport, onSend })); setBusy(true); setError(null); setSaved(false); try { const result = await operation.step(write.run, true); setSaved(true); if (result?.settings) setStored(result.settings as OrganizationFaxSettings); setAccountGeneration((n) => n + 1); // A confirmed save settles an earlier unconfirmed one: recovery only allows a complete account. holdFaxSetup(holder, { account: undefined }); // CLEARED FROM MEMORY THE MOMENT IT IS SAVED. The server never echoes these back, so leaving // them in a form field is the only place they would linger - on a shared front-desk machine. // Only the keys this save sent: ones typed while it waited are a newer draft, not saved. setApiKey((typed) => (typed === sent.apiKey ? "" : typed)); setApiSecret((typed) => (typed === sent.apiSecret ? "" : typed)); } catch (err) { // Left with no answer: it may have saved. Held for whoever sent it, even if they have moved on. if (write.unconfirmed()) holdFaxSetup(holder, { account: "unconfirmed" }); if (isSaveRetired(err) || !operation.belongs() || write.unconfirmed()) return; setError(relayServerFailure(err, "Those fax settings could not be saved.")); } finally { if (operation.belongs()) setBusy(false); operation.retire(); } }; // Settling an unconfirmed save: the whole account is entered again, and only its confirmed save ends the hold. const enterAgain = () => { holdFaxSetup(setupHolder, { account: "recovering" }); setApiKey(""); setApiSecret(""); setProjectId(""); setSaved(false); }; return (

Send referrals by fax using your own fax account.

{/* 🔑 THE ARRANGEMENT, STATED TO THE PRACTICE. It is not legal boilerplate — it is the reason we ask them for an account instead of providing one, and a practice that does not understand that will ask why they cannot just use ours. */}
{error && ( {error} )} {saved && ( Fax settings saved. )} {unresolved.account === "unconfirmed" && ( {TIMED_OUT_MAY_HAVE_SAVED} {mayConfigure && ( )} )} {unresolved.account === "recovering" && ( Enter the whole account and save it. Until that save is confirmed, which account details are stored is not known. )} {storedVendor && ( {stored?.credentialsConfigured ? `Faxes send through ${storedVendor.name}${stored.fromNumber ? ` from ${stored.fromNumber}` : ""}. Your account details are saved.` : `${storedVendor.name} is chosen, but its account details are not saved yet, so faxes cannot be sent.`} )} {!mayConfigure && capabilityState === "ready" && (

{storedVendor ? "" : "No fax account is set up yet, so faxes can’t be sent. "} Only a practice administrator can set up or change the fax account.

)} {!mayConfigure && capabilityState === "unavailable" && (

We couldn’t check whether you can change the fax account. Reload the page to try again.

)} {mayConfigure && (
Which fax provider do you use? {/* The portal stylesheet strips native radio appearance, so a bare input draws no circle at all. */} { setVendorId(next); setSaved(false); }} className="gap-2" > {VENDORS.map((vendor) => ( // Paired by htmlFor/id with the vendor name as a direct child, so the label keeps an accessible // name; the grid stacks the note under it without a wrapper. ))}
{chosen && (
setFromNumber(e.target.value)} placeholder="The number receiving offices will see" />
setApiKey(e.target.value)} autoComplete="off" />
{/* ⭐ ONLY THE VENDOR THAT NEEDS IT IS ASKED FOR IT. A field a practice cannot fill is a field they will put something wrong in. */} {chosen.needsSecret && (
setApiSecret(e.target.value)} autoComplete="off" />
)} {/* NOT A SECRET, AND DELIBERATELY NOT MASKED. It is the project the account sends from, and a practice checking they pasted the right one has to be able to read it back. */} {"needsProjectId" in chosen && chosen.needsProjectId && (
setProjectId(e.target.value)} autoComplete="off" placeholder="From your Sinch dashboard" />
)}

We store these encrypted and never show them again. To change them later, enter them once more.

)}
)} {/* Receiving needs the saved Sinch account: the services are listed with its stored keys. */} {mayConfigure && stored?.vendorId === "sinch" && stored.credentialsConfigured && ( // Its owner retires every read and enable it started: another practice, right to configure or stored account // starts it afresh. )}
); }; export default FaxSettingsPage;