import { Fragment, useEffect, useRef, useState } from 'react'; import { AlertCircle, ExternalLink, Loader2 } from 'lucide-react'; import { useAuthServiceContext } from '../../../services/AuthService.tsx'; import { useOrganizationServiceContext } from '../../../services/OrganizationService.tsx'; import { Button } from '../../../components/ui/button.tsx'; import { Input } from '../../../components/ui/input.tsx'; import { Skeleton } from '../../../components/ui/skeleton.tsx'; import { Alert, AlertDescription } from '../../../components/ui/alert.tsx'; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, } from '../../../components/ui/dialog.tsx'; import { environment } from '../../../environments/environment'; import { getPatientPortalUrl, ORGANIZATION_PATIENT_PORTAL_URL_EXTENSION_URL } from '../../../utils/patientPortalUrl'; import { getStaffPortalUrl, ORGANIZATION_STAFF_PORTAL_URL_EXTENSION_URL } from '../../../utils/staffPortalUrl'; import { getPortalPages, savePortalPage, PortalPageState, PortalPageType, type WebsiteSession, } from '../../../utils/wpAdminApi'; import { getMountContext, isWpAdminMount } from '../../../utils/mountContext'; import { hostedPortalUrl, isHostedMount } from '../../../utils/hostedMount'; import { isPublicPortalAddress } from '../../../utils/publicPortalAddress'; import { portalPagesReadable } from '../../../utils/portalPagesReadable'; import { addressParts } from '../../../utils/addressParts'; import './PatientPortalPageSettings.css'; import { relayServerFailure } from '../../../utils/fetchFailureMessage'; import { isSaveRetired, useSaveScope } from '../../../utils/saveOperation'; import { writeThroughViewer } from '../../../utils/writeThroughHold'; import { useRefreshSessionOrganization } from '../../../hooks/useRefreshSessionOrganization'; import { holdSettingsWrite, newSettingsOperationId, nextSettingsAttempt, recordSettingsAttempt, settingsWriteHolder, unresolvedSettingsWrite, useUnresolvedSettingsWrite, watchSettingsWrite, } from '../../../utils/unresolvedSettingsWrite'; type PortalPagesState = { patient: PortalPageState; staff: PortalPageState }; const LABELS: Record = { patient: 'Patient', staff: 'Staff', }; // What a new page starts as; both stay editable before it is created. const DEFAULT_TITLES: Record = { patient: 'Patient Portal', staff: 'Staff Portal', }; const slugify = (value: string): string => value .trim() .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, ''); // One page write exactly as it was sent, with the operation it is (the website answers the same operation again with // what it did) and, for an edit, the page it is for and the title and address it was opened with. type PageRequest = { type: PortalPageType; mode: 'create' | 'edit'; title: string; slug: string; operation: string; pageId?: number; expectedTitle?: string; expectedSlug?: string; expectedRevision?: number; }; const readPageRequest = (detail?: string): PageRequest | undefined => { try { const request = JSON.parse(detail ?? '') as PageRequest; return (request.type === 'patient' || request.type === 'staff') && (request.mode === 'create' || request.mode === 'edit') && typeof request.title === 'string' && typeof request.slug === 'string' && typeof request.operation === 'string' ? request : undefined; } catch { return undefined; } }; // What the website shows about a held page write. A create is done once the portal has a page. An edit's own page // replaced by another is overtaken; on its own page, what it shows cannot tell whether this edit landed (WordPress may // have reworded the address), so it is pending until the same operation, replayed, says what it did. const pageSettles = (page: PortalPageState, request: PageRequest): 'done' | 'overtaken' | 'pending' => { if (request.mode === 'create') return page.status === 'owned' ? 'done' : 'pending'; return page.status === 'owned' && page.id === request.pageId ? 'pending' : 'overtaken'; }; // A link write as it was sent: the link, and the link it was decided from. type LinkRequest = { url: string; expected: string; revision: number }; const readLinkRequest = (detail?: string): LinkRequest | undefined => { try { const request = JSON.parse(detail ?? '') as LinkRequest; return typeof request.url === 'string' && typeof request.expected === 'string' && Number.isInteger(request.revision) ? request : undefined; } catch { return undefined; } }; /** The path of a saved page address, as a reader sees it in a link: /patient-portal/. */ const pagePath = (url: string): string => { try { return new URL(url).pathname; } catch { return url; } }; // `embedded`: the setup guide's overlay carries the title, so the page header gives way to what the step creates. // `onPagesChange`: the setup guide counts a page this site owns even when its address is never saved as a link. // Everything here belongs to the practice and viewer it started for; a write with no answer is held until settled. const PatientPortalPageSettings = ({ embedded = false, onPagesChange }: { embedded?: boolean; onPagesChange?: (pages: PortalPagesState) => void; } = {}) => { const { currentOrganization, setWpOrganization, getValidToken } = useAuthServiceContext(); const { updatePatientPortalUrl, updateStaffPortalUrl, fetchMyPracticeHostname } = useOrganizationServiceContext(); const refreshOrganization = useRefreshSessionOrganization(); // wp-admin acts as the WordPress user (nonce); the staff portal on the practice's own site acts as // the signed-in Akashic user, whom the Akashic API authorizes for this site. An older plugin build // localizes neither, and the practice subdomain has no WordPress at all. const wpAdmin = isWpAdminMount(); const sessionManaged = getMountContext() === 'staff' && !isHostedMount() && Boolean(environment.wpData?.wpAjaxUrl); const wpDataAvailable = portalPagesReadable(); const session: WebsiteSession = { getToken: getValidToken, organizationId: currentOrganization?.id }; const [reloadCount, setReloadCount] = useState(0); const owner = `${currentOrganization?.id ?? ''}|${writeThroughViewer() ?? ''}`; // The pages as last read, with whom they were read for: shown, healed from and edited only for that owner. const [loaded, setLoaded] = useState<{ owner: string; pages: PortalPagesState; read: number } | null>(null); // Which read's missing links have been checked: once per read. const healedRead = useRef(0); const reads = useRef(0); const pages = loaded?.owner === owner ? loaded.pages : null; useEffect(() => { if (pages) onPagesChange?.(pages); // eslint-disable-next-line react-hooks/exhaustive-deps }, [pages]); const [loading, setLoading] = useState(true); const [loadError, setLoadError] = useState(); const [modalType, setModalType] = useState(null); const [modalMode, setModalMode] = useState<'create' | 'edit'>('create'); const [title, setTitle] = useState(''); const [slug, setSlug] = useState(''); const [slugTouched, setSlugTouched] = useState(false); const [saving, setSaving] = useState(false); const [saveError, setSaveError] = useState(); // The page being edited, and whether the reader has seen that its address will change. const [editingPage, setEditingPage] = useState | null>(null); const [confirmingNewAddress, setConfirmingNewAddress] = useState(false); // The saved link a manager has asked to replace with this site's page address, pending their confirm. const [adopting, setAdopting] = useState(null); const [adoptError, setAdoptError] = useState(); // The page moved but the saved link did not: the two writes fail apart, so they are said apart. const [linkError, setLinkError] = useState(); // A held write's own check or resend that did not settle it, by type. const [heldNotice, setHeldNotice] = useState>>({}); // A held page write that a check found still pending: only then is it sent again. const [stillPending, setStillPending] = useState>>({}); const scope = useSaveScope(owner, () => { setLoaded(null); setLoadError(undefined); setModalType(null); setSaving(false); setSaveError(undefined); setEditingPage(null); setConfirmingNewAddress(false); setAdopting(null); setAdoptError(undefined); setLinkError(undefined); setHeldNotice({}); setStillPending({}); }); const pageHolder = (type: PortalPageType) => settingsWriteHolder(`portal-page-${type}`); const linkHolder = (type: PortalPageType) => settingsWriteHolder(`portal-link-${type}`); const heldPage = { patient: useUnresolvedSettingsWrite(pageHolder('patient')), staff: useUnresolvedSettingsWrite(pageHolder('staff')), }; const heldLink = { patient: useUnresolvedSettingsWrite(linkHolder('patient')), staff: useUnresolvedSettingsWrite(linkHolder('staff')), }; const heldPageRequest = (type: PortalPageType): PageRequest | undefined => readPageRequest(heldPage[type]?.detail); const heldLinkRequest = (type: PortalPageType): LinkRequest | undefined => readLinkRequest(heldLink[type]?.detail); // The practice's Akashic-hosted address. On the hosted mount it is this origin; on a WordPress site it // is the hostname the practice holds, which only the server knows. Undefined when none is held or the // read is refused, and then the rows name the WordPress page alone. const [heldOrigin, setHeldOrigin] = useState(); useEffect(() => { setHeldOrigin(undefined); if (isHostedMount()) return; let active = true; fetchMyPracticeHostname() .then((mine) => { if (active && mine.claimed && mine.hostname) setHeldOrigin(`https://${mine.hostname}`); }) .catch((err) => console.error('Error reading the practice address:', err)); return () => { active = false; }; }, [fetchMyPracticeHostname, currentOrganization?.id]); const hostedUrl = (type: PortalPageType): string | undefined => hostedPortalUrl(type) ?? (heldOrigin ? `${heldOrigin}${type === 'staff' ? '/staff-portal' : '/'}` : undefined); const storedUrl = (type: PortalPageType): string | undefined => type === 'patient' ? getPatientPortalUrl(currentOrganization) : getStaffPortalUrl(currentOrganization); // The saved link's revision, kept beside it by the server and raised by every write of it. const linkRevision = (type: PortalPageType): number => (currentOrganization?.extension ?? []).find((e) => e.url === `${ type === 'patient' ? ORGANIZATION_PATIENT_PORTAL_URL_EXTENSION_URL : ORGANIZATION_STAFF_PORTAL_URL_EXTENSION_URL}-revision`)?.valueInteger ?? 0; useEffect(() => { if (!wpDataAvailable) { setLoading(false); return; } const operation = scope.start(); const readFor = owner; setLoading(true); setLoadError(undefined); operation.step(() => getPortalPages(session, operation.transport)) .then((result) => { // What the website shows about a page write held for this owner: done or overtaken settles it. const pending: Partial> = {}; (['patient', 'staff'] as const).forEach((type) => { const holder = pageHolder(type); const request = readPageRequest(unresolvedSettingsWrite(holder)?.detail); if (!request) return; const settles = pageSettles(result[type], request); if (settles === 'pending') { pending[type] = true; return; } holdSettingsWrite(holder, undefined); if (settles === 'overtaken') { setLinkError(`Another page has since taken over the ${LABELS[type].toLowerCase()} portal, so that change is not on the current page.`); } }); setStillPending(pending); reads.current += 1; setLoaded({ owner: readFor, pages: result, read: reads.current }); }) .catch((err) => { if (isSaveRetired(err) || !operation.belongs()) return; console.error('Error fetching portal pages:', err); setLoadError(relayServerFailure(err, 'Failed to load portal pages.')); }) .finally(() => { if (operation.belongs()) setLoading(false); operation.retire(); }); return () => operation.retire(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [wpDataAvailable, owner, reloadCount]); // One saved-link write for the practice and viewer it starts under, written by the server only over `expected` (the // link it was decided from). Its outcome: saved and installed, refused (nothing written; `changed` when the link had // moved), unknown (held with what it sent) or retired (nothing shown). const publishLink = async (type: PortalPageType, url: string, expected: string, revision: number): Promise<{ outcome: 'saved' | 'refused' | 'changed' | 'unknown' | 'retired'; error?: unknown; }> => { const operation = scope.start(); const heldFor = linkHolder(type); const startedWith = unresolvedSettingsWrite(heldFor); const attempt = nextSettingsAttempt(startedWith); const write = watchSettingsWrite((onSend) => (type === 'patient' ? updatePatientPortalUrl(url, { ...operation.transport, onSend }, expected, revision) : updateStaffPortalUrl(url, { ...operation.transport, onSend }, expected, revision))); try { const updated = await operation.step(write.run, true); holdSettingsWrite(heldFor, undefined); setWpOrganization(updated.organization); return { outcome: 'saved' }; } catch (error) { recordSettingsAttempt(heldFor, attempt, startedWith, write.unconfirmed() ? { kind: 'portal-link', state: 'unconfirmed', detail: JSON.stringify({ url, expected, revision }) } : undefined); if (isSaveRetired(error) || !operation.belongs()) return { outcome: 'retired' }; if (write.unconfirmed()) return { outcome: 'unknown', error }; // The link moved since this was decided: this write, and any earlier one decided from the same link, can never // land. The practice is read again so the screen shows the link it has; a practice or viewer that changed // during that read is told nothing, whether the read was retired or failed for it. const changed = (error as { body?: { code?: unknown } } | null)?.body?.code === 'PORTAL_LINK_CHANGED'; if (!changed) return { outcome: 'refused', error }; if (startedWith) holdSettingsWrite(heldFor, undefined); await refreshOrganization(operation.transport).catch(() => undefined); if (!operation.belongs()) return { outcome: 'retired' }; return { outcome: 'changed', error }; } finally { operation.retire(); } }; // Once per load, for both pages: when this site owns a page and the org has no stored URL for it, // write the page's URL. It never replaces a stored URL - a copy of the site owns pages at its own // address - and a stored URL that differs changes only on an explicit confirm. A page nobody outside // the office can open (a developer's https://.local) is never written: emails would carry it. // Only from pages read for this same practice and viewer, and never over a link write still held. useEffect(() => { if (!pages || !loaded || loaded.read === healedRead.current || !currentOrganization) { return; } healedRead.current = loaded.read; const patient = pages.patient; const staff = pages.staff; const healPatient = patient.status === 'owned' && isPublicPortalAddress(patient.url) && !getPatientPortalUrl(currentOrganization) && !heldLink.patient; const healStaff = staff.status === 'owned' && isPublicPortalAddress(staff.url) && !getStaffPortalUrl(currentOrganization) && !heldLink.staff; if (!healPatient && !healStaff) return; // THE TWO WRITES ARE SEQUENTIAL, AND THAT IS NOT TIDINESS. Each endpoint does a // read-modify-PUT of the WHOLE Organization, because a FHIR PUT replaces the resource. Fired // together, the loser reads the org BEFORE the winner saves and then PUTs that stale copy back - // so one of the two URLs silently reverts, on a screen running in an effect that nobody watches. // Awaiting the first is what makes the second read the org the first one just wrote. // // item 32 - these went through the generic Organization PUT, which is // ADMINS/organization:manage. This screen is PM-gated, so a Platform Manager was DENIED - and // because it runs in an effect, the 403 fired on LOAD and vanished into a console.error. The // dedicated endpoints write only their own extension with the server's own system token. void (async () => { if (healPatient && patient.status === 'owned') { // Each write's failure is its own: one does not skip healing the other. const healed = await publishLink('patient', patient.url, '', linkRevision('patient')); if (healed.outcome === 'retired') return; if (healed.outcome === 'refused') console.error('Error self-healing patient portal URL:', healed.error); } if (healStaff && staff.status === 'owned') { const healed = await publishLink('staff', staff.url, '', linkRevision('staff')); if (healed.outcome === 'refused') console.error('Error self-healing staff portal URL:', healed.error); } })(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [loaded, pages, currentOrganization]); const openCreate = (type: PortalPageType) => { setModalType(type); setModalMode('create'); setTitle(DEFAULT_TITLES[type]); setSlug(slugify(DEFAULT_TITLES[type])); setSlugTouched(false); setSaveError(undefined); setEditingPage(null); setConfirmingNewAddress(false); }; const openEdit = (type: PortalPageType, page: Extract) => { setModalType(type); setModalMode('edit'); setTitle(page.title); setSlug(page.slug); setSlugTouched(true); setSaveError(undefined); setEditingPage(page); setConfirmingNewAddress(false); }; const closeModal = () => { if (saving) return; setModalType(null); }; const handleTitleChange = (value: string) => { setTitle(value); if (modalMode === 'create' && !slugTouched) { setSlug(slugify(value)); } }; const handleSlugChange = (value: string) => { setSlug(value); setSlugTouched(true); setConfirmingNewAddress(false); }; // One page write, then the saved link it implies. The page and the link are separate writes and are reported // apart: a page saved while its link is refused or unknown says exactly that. // `replay`: the same operation sent again. Its success is believed as current only once the website, read again, // shows that page still holding the portal; a historical page is never installed or published as the current one. const sendPage = async (request: PageRequest, replay = false): Promise<{ outcome: 'done' | 'refused' | 'unknown' | 'retired' | 'superseded' | 'unverified'; error?: unknown; }> => { const { type } = request; const operation = scope.start(); const heldFor = pageHolder(type); const startedWith = unresolvedSettingsWrite(heldFor); const attempt = nextSettingsAttempt(startedWith); const write = watchSettingsWrite((onSend) => savePortalPage(type, request.mode, request.title, request.slug, session, { ...operation.transport, onSend }, { operation: request.operation, pageId: request.pageId, expectedTitle: request.expectedTitle, expectedSlug: request.expectedSlug, expectedRevision: request.expectedRevision, })); const readFor = owner; setSaving(true); setSaveError(undefined); setLinkError(undefined); try { let result: PortalPageState; try { result = await operation.step(write.run, true); } catch (err) { recordSettingsAttempt(heldFor, attempt, startedWith, write.unconfirmed() ? { kind: 'portal-page', state: 'unconfirmed', detail: JSON.stringify(request) } : undefined); if (isSaveRetired(err) || !operation.belongs()) return { outcome: 'retired' }; if (write.unconfirmed()) return { outcome: 'unknown' }; console.error('Error saving portal page:', err); setSaveError(relayServerFailure(err, 'Failed to save the page. Please try again.')); return { outcome: 'refused', error: err }; } holdSettingsWrite(heldFor, undefined); if (replay) { // The write is done. Whether its page still holds the portal is what this read says; a read that fails // says neither, so nothing is published and the pages are read again. let now: Awaited>; try { now = await operation.step(() => getPortalPages(session, operation.transport)); } catch (err) { if (isSaveRetired(err) || !operation.belongs()) return { outcome: 'retired' }; return { outcome: 'unverified', error: err }; } reads.current += 1; setLoaded({ owner: readFor, pages: now, read: reads.current }); const page = now[type]; if (page.status !== 'owned' || result.status !== 'owned' || page.id !== result.id) return { outcome: 'superseded' }; result = page; } setLoaded((current) => (current && current.owner === readFor ? { ...current, pages: { ...current.pages, [type]: result } } : current)); // A local preview keeps its page; only an address people outside the office can open becomes the saved link. if (result.status === 'owned' && currentOrganization && isPublicPortalAddress(result.url)) { // Same reason as the self-heal above - the generic Organization PUT denies a PM, and // this is the save path they came here to use. One page is saved at a time here, so // there is no second write to sequence against. const linked = await publishLink(type, result.url, storedUrl(type) ?? '', linkRevision(type)); if (linked.outcome === 'retired' || !operation.belongs()) return { outcome: 'retired' }; if (linked.outcome === 'refused' || linked.outcome === 'changed') { console.error('Portal page saved, but the saved link was not updated:', linked.error); setLinkError(`The page is now at ${result.url}, but the saved ${LABELS[type].toLowerCase()} link was not updated. ${relayServerFailure(linked.error, 'Please try again.')}`); } } return { outcome: 'done' }; } finally { if (operation.belongs()) setSaving(false); operation.retire(); } }; const handleSave = async () => { if (!modalType || !title.trim()) { setSaveError('Enter a page title.'); return; } // A new address for a live page is reviewed before it is saved: links already sent point at the old one. const addressChanges = modalMode === 'edit' && editingPage !== null && slug.trim() !== editingPage.slug; if (addressChanges && !confirmingNewAddress) { setConfirmingNewAddress(true); return; } const { outcome } = await sendPage({ type: modalType, mode: modalMode, title: title.trim(), slug: slug.trim(), operation: newSettingsOperationId(), ...(modalMode === 'edit' && editingPage ? { pageId: editingPage.id, expectedTitle: editingPage.title, expectedSlug: editingPage.slug, expectedRevision: editingPage.revision } : {}), }); // A refusal keeps the dialog and its draft for correction; anything else closes it (an unknown write is held). if (outcome === 'done' || outcome === 'unknown') setModalType(null); }; // The explicit half of the rule above: a manager replaces a saved link with this site's page address // only after being shown which link changes, from what, to what. const adoptPageAddress = async (type: PortalPageType, url: string) => { setSaving(true); setAdoptError(undefined); const linked = await publishLink(type, url, storedUrl(type) ?? '', linkRevision(type)); if (linked.outcome === 'retired') return; setSaving(false); if (linked.outcome === 'refused' || linked.outcome === 'changed') { console.error('Error replacing the saved portal link:', linked.error); setAdoptError(relayServerFailure(linked.error, 'The saved link was not changed. Please try again.')); return; } setAdopting(null); if (linked.outcome === 'saved') setLinkError(undefined); }; // A held link write is settled by sending that same write again, decided from the same link: the server writes it // only over that link, so it lands, is found already done, or finds the link moved - and the first one with it. const resendLink = async (type: PortalPageType, request: LinkRequest) => { setSaving(true); setHeldNotice((current) => ({ ...current, [type]: undefined })); const linked = await publishLink(type, request.url, request.expected, request.revision); if (linked.outcome === 'retired') return; setSaving(false); if (linked.outcome === 'changed') { setLinkError(relayServerFailure(linked.error, 'The saved link was changed elsewhere, so this change was not made.')); return; } if (linked.outcome === 'refused') { setHeldNotice((current) => ({ ...current, [type]: relayServerFailure(linked.error, 'The saved link was not changed. Try again in a minute.') })); } }; // A held page write: first what the website shows, which settles it when it shows that write done or overtaken; // only a write it shows still pending is sent again, as the same operation, which the website answers with what it // did rather than doing twice. const checkPage = () => { setHeldNotice({}); setStillPending({}); setReloadCount((count) => count + 1); }; const resendPage = async (request: PageRequest) => { setHeldNotice((current) => ({ ...current, [request.type]: undefined })); setStillPending((current) => ({ ...current, [request.type]: false })); const { outcome, error } = await sendPage(request, true); if (outcome === 'superseded') { setLinkError(`That change was made, but another page has since taken over the ${LABELS[request.type].toLowerCase()} portal. The current page is shown.`); return; } if (outcome === 'unverified') { setLinkError(`That change was made, but the website could not be read to confirm which page holds the ${LABELS[request.type].toLowerCase()} portal now, so its saved link was not changed. The pages are being read again.`); setReloadCount((count) => count + 1); return; } if (outcome !== 'refused') return; setSaveError(undefined); // Busy: another save of this page is still running, so this one stays held. Any other refusal of the same // operation says what became of it (overtaken, or never applied) in WordPress's own words. if ((error as { refusal?: unknown } | null)?.refusal === 'operation_busy') { setHeldNotice((current) => ({ ...current, [request.type]: relayServerFailure(error, 'Try again in a moment.') })); return; } holdSettingsWrite(pageHolder(request.type), undefined); setLinkError(relayServerFailure(error, 'The page change was not made.')); if ((error as { refusal?: unknown } | null)?.refusal === 'page_superseded') setReloadCount((count) => count + 1); }; // A write held for this type, with the one action that settles it. const renderHeld = (type: PortalPageType) => { const who = LABELS[type].toLowerCase(); const page = heldPage[type] ? heldPageRequest(type) : undefined; const link = heldLink[type] ? heldLinkRequest(type) : undefined; if (!page && !link) return null; return ( {page && ( <> We could not confirm whether the {who} portal page was {page.mode === 'create' ? 'created' : 'changed'}{page.slug ? ` at /${page.slug}/` : ''}.{' '} {pages && stillPending[type] && ( )} )} {link && ( <> We could not confirm whether the saved {who} link was changed to {link.url}.{' '} )} {heldNotice[type] && {heldNotice[type]}} ); }; // Each address with the kind of door it is. On a WordPress site its own page is the main sign-in page // and the hosted address works beside it, so the page comes first there. const renderAddresses = (links: { source: string; href?: string }[]) => ( <> {links .filter((link): link is { source: string; href: string } => Boolean(link.href)) .map((link) => ( ))} ); // A saved link that is not this site's page, or not the practice address: say so, keep it, and offer // the change as a reviewed step. const renderSavedLinkDiffers = (type: PortalPageType, saved: string, pageUrl: string, target: 'page' | 'practice' = 'page') => { const who = LABELS[type].toLowerCase(); const targetName = target === 'page' ? 'this page' : 'this practice address'; const effect = type === 'patient' ? 'Patient notification emails will link to the new address.' : 'Staff invitations and contractor links will use the new address.'; return ( {heldLink[type] ? ( <>Your saved {who} link is {saved}, not {targetName}. It can be changed once the change below is settled. ) : adopting === type ? ( <> Change the saved {who} link from {saved} to {pageUrl}? {effect} {adoptError && {adoptError}} ) : ( <> Your saved {who} link is {saved}, not {targetName}.{' '} {!isPublicPortalAddress(saved) && `Nobody outside the office can open it, so ${type === 'patient' ? 'emails' : 'invitations'} do not use it. `} Nothing changes unless you choose to.{' '} )} ); }; const WORDPRESS = 'WordPress page'; const SAVED: Record = { patient: 'Saved link, used in emails', staff: 'Saved link, used in invitations' }; const savedLabel = (type: PortalPageType, url: string | undefined) => isPublicPortalAddress(url) ? SAVED[type] : 'Saved link, which nobody outside the office can open'; const emailsUseSavedLink = !getPatientPortalUrl(currentOrganization) || isPublicPortalAddress(getPatientPortalUrl(currentOrganization)); const HOSTED = isHostedMount() ? 'Akashic-hosted' : 'Akashic-hosted, also live'; // The wp-admin dashboard URL of the site this mount is running inside, derived from the plugin's // own URL rather than from window.location so a WordPress install in a subdirectory still resolves. // Undefined when the app is not running inside WordPress at all, which is the practice-subdomain // mount - there the pages exist by construction and there is no dashboard to send anybody to. const wpAdminUrl = (() => { const pluginUrl: string | undefined = environment.wpData?.pluginUrl; const marker = pluginUrl?.indexOf('/wp-content/') ?? -1; if (!pluginUrl || marker < 0) return undefined; return `${pluginUrl.slice(0, marker)}/wp-admin/admin.php?page=akashic-health-records-patient-portal`; })(); // The read-only half of this screen: the practice subdomain (no WordPress), an older plugin build, // or a read that did not answer. The resolved URLs live on the Organization, so it can still say // which side of the requirement the practice is on. The stored URL is named as the saved link, since // that is what emails carry, whatever page it once came from. On the practice address, or when nobody // outside the office can open it, the practice address is offered as a reviewed change. const renderStoredRow = (type: PortalPageType, restricted = false) => { const label = LABELS[type]; const saved = storedUrl(type); const practice = hostedUrl(type); const stored = { source: savedLabel(type, saved), href: saved }; const hosted = { source: HOSTED, href: practice }; const links = isHostedMount() ? [hosted, stored] : [stored, hosted]; const offerPractice = Boolean(saved && practice && saved !== practice && isPublicPortalAddress(practice)) && (isHostedMount() || !isPublicPortalAddress(saved)); return (
{label} Portal Page: {links.some((link) => link.href) ? (
{restricted && URL saved} {renderAddresses(links)} {offerPractice ? renderSavedLinkDiffers(type, saved as string, practice as string, 'practice') : null} {renderHeld(type)}
) : restricted ? (
Not configured Connect the website and verify publishing access to create this page.
) : ( {type === 'patient' ? 'Not published yet, so patients have no page to sign in on.' : 'Not published yet, so staff sign in through the WordPress dashboard.'} )}
); }; const renderRow = (type: PortalPageType) => { const label = LABELS[type]; const page = pages?.[type]; const pageHeld = Boolean(heldPage[type]) || Boolean(heldLink[type]); const editable = !loading && page?.status === 'owned' && !pageHeld; return (
{label} Portal Page: {loading ? ( ) : page?.status === 'owned' ? ( <>
{renderAddresses([{ source: WORDPRESS, href: page.url }, { source: HOSTED, href: hostedUrl(type) }])} {storedUrl(type) && storedUrl(type) !== page.url && isPublicPortalAddress(page.url) ? renderSavedLinkDiffers(type, storedUrl(type) as string, page.url) : null} {renderHeld(type)}
{!pageHeld && ( )} ) : pageHeld ? (
{renderHeld(type)}
) : ( <> {renderHeld(type)} )}
); }; return (
{embedded ? (

Create the pages patients and staff will use on your website.

) : (

Portal Pages

{isHostedMount() ? 'Where patients and staff sign in to this practice.' : 'Manage where patients and staff sign in on your WordPress website.'} {emailsUseSavedLink && ' Patient notification emails use the saved patient portal link.'}

)} {linkError && ( {linkError} )} {!wpAdmin && !sessionManaged ? ( <>
{renderStoredRow('patient')}
{renderStoredRow('staff')}

{isHostedMount() ? 'Your portals are always live at this practice address. A page on your own WordPress website is optional, and publishing or renaming one is done by a site administrator in the WordPress dashboard.' : "Portal pages are pages on your practice's own website, so publishing or renaming one is done by a site administrator in the WordPress dashboard."} {wpAdminUrl ? ( <> {' '} Open this settings page in wp-admin ) : null}

) : !wpDataAvailable ? ( Your plugin build needs to be updated. Reload wp-admin after the plugin has been updated, then come back to this page. ) : loadError ? ( // An unanswered read is not "no page": show what the practice has saved, and retry. <>
{renderStoredRow('patient', true)}
{renderStoredRow('staff', true)}

Website publishing unavailable

{/* What the read actually said, so a timeout or server fault is not passed off as missing access. */}

{loadError} Your saved links are unchanged.

We could not verify publishing access to {window.location.hostname}. Ask the site owner to connect the site or authorize scoped Akashic access, then check again. Full WordPress Administrator access is not required.

) : (
{renderRow('patient')}
{renderRow('staff')}
)} {embedded &&

Later, either page can be changed from Portal Pages by someone with the Platform Manager workspace.

} !open && closeModal()}> {modalMode === 'create' ? 'Create' : 'Edit'} {modalType ? LABELS[modalType].toLowerCase() : ''} portal page {saveError && ( {saveError} )}
handleTitleChange(event.target.value)} disabled={saving} autoFocus />
handleSlugChange(event.target.value)} disabled={saving} />
{/* The patient page's address is in every notification email already sent, so its cost is stated before anyone edits it, not only once they try to save a new one. */} {modalMode === 'edit' && modalType === 'patient' && editingPage && (
This address is the login link. Every notification email already sent to a patient points at {pagePath(editingPage.url)}. Change it and those links stop working — only emails sent from now on will carry the new address.
)} {confirmingNewAddress && editingPage && ( This changes the page address from {editingPage.url}. {modalType === 'staff' && ' Staff who saved the old address will need the new one.'} )}
); }; export default PatientPortalPageSettings;