import { useEffect, useRef, useState, useSyncExternalStore } from "react"; import { Mail, Send, Undo2 } from "lucide-react"; import { Card } from "../../../components/ui/card"; import { Button } from "../../../components/ui/button"; import type { TeamMembership, ResendInvitationResponse } from "../../../services"; import { onViewerChange, viewerEpoch } from "../../../utils/writeThroughHold"; import { mayHaveBeenWritten, relayServerFailure } from "../../../utils/fetchFailureMessage"; import { RegistryIdentity } from "./PendingApprovalsSection"; interface PendingExternalInvitesProps { teamId: string; members: TeamMembership[]; resendExternalInvite: (teamId: string, email: string, invitationGeneration: string) => Promise; // Taking an invitation back, for those who may send it; its link stops working. withdrawExternalInvite?: (teamId: string, memberRef: string, invitationGeneration: string) => Promise; // Reads the team again after a change here, so the next action names the invitation as it now stands. onChanged?: () => void; // False while the list behind this team is being read again or could not be: no decision is offered on it. decisionsOpen?: boolean; } // States in which an emailed invitation is still outstanding. Anything else is finished, and the // server refuses a resend for it - resend must not resurrect a closed invitation. const OUTSTANDING = new Set(["invited", "pending-patient-approval"]); const UNCONFIRMED_WITHDRAWAL = "We could not confirm the invitation was withdrawn. Reload the team to check before trying again."; // The server refused because the invitation shown was replaced; the team is read again rather than asking for a reload. const wasReplaced = (failure: unknown) => (failure as { status?: number })?.status === 409 && ["INVITE_CHANGED", "INVITATION_CHANGED"].includes((failure as { body?: { code?: string } })?.body?.code ?? ""); // Only an answer naming the invitation that was chosen proves it was withdrawn. const confirmsWithdrawal = (answer: unknown, member: TeamMembership) => { const said = (answer ?? {}) as Record; return said.state === "removed" && said.memberRef === member.memberRef && said.invitationGeneration === member.invitationGeneration; }; const formatExpiry = (iso?: string) => { if (!iso) return null; const date = new Date(iso); return Number.isNaN(date.getTime()) ? null : date.toLocaleDateString(); }; // S1b (client half) - re-send a lost or expired invitation. // // WHY IT EXISTS: the invite write is a CONDITIONAL put, so re-inviting an outstanding invite 409s. // Without a resend there is genuinely no route back from a lost email - the documented dead end in // the org setup-token pattern, which S1b deliberately did not inherit. // // TWO TRUTHS THE UI OWES THE USER, both consequences of how the server re-mints: // 1. Re-sending INVALIDATES the previous link (the stored digest is overwritten). If the invitee // still has the first email, resending silently kills it - so this confirms first. // 2. The old token dies BEFORE the new email is queued, so a send failure leaves the invitee with // no working link at all. A bare "try again" would understate that; the failure copy says it. export const PendingExternalInvites = ({ teamId, members, resendExternalInvite, withdrawExternalInvite, onChanged, decisionsOpen = true, }: PendingExternalInvitesProps) => { const epoch = useSyncExternalStore(onViewerChange, viewerEpoch); const scopeKey = JSON.stringify([teamId, epoch]); const scope = useRef({ key: scopeKey }); if (scope.current.key !== scopeKey) scope.current = { key: scopeKey }; type Notice = { scope: typeof scope.current; memberRef: string; email: string; state: string; original: string; returned: string; adopted: boolean; text: string }; const [notice, setNotice] = useState(null); const noticedMember = notice && members.find(member => member.memberRef === notice.memberRef); const noticeStands = notice && notice.scope === scope.current && noticedMember?.memberClass === 'external-practitioner' && noticedMember.inviteEmail === notice.email && noticedMember.state === notice.state && (noticedMember.invitationGeneration === notice.returned || !notice.adopted && noticedMember.invitationGeneration === notice.original); if (notice && !noticeStands) setNotice(null); else if (notice && !notice.adopted && noticedMember?.invitationGeneration === notice.returned) { setNotice({ ...notice, adopted: true }); } const identity = JSON.stringify([teamId, epoch, members.filter(member => member.memberClass === 'external-practitioner') .map(member => [member.memberRef, member.invitationGeneration, member.state])]); const opening = useRef({ identity }); if (opening.current.identity !== identity) opening.current = { identity }; const owner = opening.current; const mounted = useRef(true); const inflight = useRef(); useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []); type Confirmation = { email: string; kind: 'resend' | 'withdraw'; generation?: string } | null; type State = { owner: typeof owner; confirming: Confirmation; busy: boolean; error: string | null; sent: string | null; withdrawn: string[] }; const blank: State = { owner, confirming: null, busy: false, error: null, sent: null, withdrawn: [] }; const [state, setState] = useState(blank); const [replacement, setReplacement] = useState<{ scope: typeof scope.current; generations: string[] }>( { scope: scope.current, generations: [] }); const replaced = replacement.scope === scope.current ? replacement.generations : []; const current = state.owner === owner ? state : blank; const belongs = () => mounted.current && opening.current === owner && viewerEpoch() === epoch; const update = (key: K, value: State[K] | ((old: State[K]) => State[K])) => { if (!belongs()) return; setState(previous => { const base = previous.owner === owner ? previous : blank; return { ...base, [key]: typeof value === 'function' ? (value as (old: State[K]) => State[K])(base[key]) : value }; }); }; const { confirming, busy, error, sent, withdrawn } = current; const setConfirming = (value: Confirmation) => update('confirming', value); const setBusy = (value: boolean) => update('busy', value); const setError = (value: string | null) => update('error', value); const setSent = (value: string | null) => update('sent', value); const setWithdrawn = (value: string[] | ((old: string[]) => string[])) => update('withdrawn', value); const setReplaced = (value: string[] | ((old: string[]) => string[])) => { if (!belongs()) return; const atScope = scope.current; setReplacement(previous => { const generations = previous.scope === atScope ? previous.generations : []; return { scope: atScope, generations: typeof value === 'function' ? value(generations) : value }; }); }; const confirmingFor = (member: TeamMembership) => confirming !== null && confirming.email === member.inviteEmail && confirming.generation === member.invitationGeneration; const isReplaced = (member: TeamMembership) => replaced.includes(member.invitationGeneration ?? ""); const awaitingRead = members.some(isReplaced); const outstanding = members.filter( (member) => member.memberClass === "external-practitioner" && OUTSTANDING.has(member.state) && Boolean(member.inviteEmail) && // A fresh invitation to the same address is a different one, and shows. !withdrawn.includes(member.invitationGeneration ?? ""), ); const shownNotice = noticeStands ? notice.text : sent; if (outstanding.length === 0 && !shownNotice) return null; const resend = async (member: TeamMembership) => { if (!member.invitationGeneration || !belongs() || inflight.current === owner) return; inflight.current = owner; const email = member.inviteEmail!; setBusy(true); setError(null); setSent(null); try { const answer = await resendExternalInvite(teamId, email, member.invitationGeneration); if (!belongs()) return; if (!answer || !['invitation-rotated', 'invitation-resent'].includes(answer.status)) throw new Error('Unconfirmed invitation'); setConfirming(null); const text = answer.delivery === 'queued' ? `A new link was made and the invitation email was handed off for ${email}.` : answer.delivery === 'not-sent' ? 'A new link was made, but no email was sent. The previous link no longer works.' : answer.delivery === 'unknown' ? 'A new link was made. We could not confirm the email was handed off. The previous link no longer works; check with them before sending again.' : `A new link was made for ${email}.`; if (typeof answer.invitationGeneration === 'string' && answer.invitationGeneration.length > 0) { setNotice({ scope: scope.current, memberRef: member.memberRef, email, state: member.state, original: member.invitationGeneration, returned: answer.invitationGeneration, adopted: false, text }); } else setSent(text); setReplaced(names => [...names, member.invitationGeneration!]); onChanged?.(); } catch (failure) { if (!belongs()) return; if (wasReplaced(failure)) { setConfirming(null); setReplaced(names => [...names, member.invitationGeneration!]); onChanged?.(); } else { const status = (failure as { status?: number })?.status; setError(typeof status === 'number' && status < 500 ? relayServerFailure(failure, 'The new invitation was refused. Reload before trying again.') : 'We could not confirm the new invitation. Check with them before sending again, because another send replaces its link.'); } } finally { if (belongs() && inflight.current === owner) { inflight.current = undefined; setBusy(false); } } }; const withdraw = async (member: TeamMembership) => { if (!withdrawExternalInvite || !member.invitationGeneration || !belongs() || inflight.current === owner) return; inflight.current = owner; setBusy(true); setError(null); setSent(null); try { const answer = await withdrawExternalInvite(teamId, member.memberRef, member.invitationGeneration); if (!belongs()) return; if (!confirmsWithdrawal(answer, member)) { setError(UNCONFIRMED_WITHDRAWAL); return; } const chosen = member.invitationGeneration; setConfirming(null); setWithdrawn((names) => [...names, chosen]); setSent("The invitation to " + member.inviteEmail + " was withdrawn. Its link no longer works."); onChanged?.(); } catch (failure) { if (!belongs()) return; if (wasReplaced(failure)) { const stale = member.invitationGeneration; setConfirming(null); setReplaced((names) => [...names, stale]); setError(null); onChanged?.(); return; } setError( mayHaveBeenWritten(failure) ? UNCONFIRMED_WITHDRAWAL : relayServerFailure(failure, "Could not withdraw the invitation. Its link still works. Try again."), ); } finally { if (belongs() && inflight.current === owner) { inflight.current = undefined; setBusy(false); } } }; return (

Invitations sent by email

{error &&

{error}

} {shownNotice &&

{shownNotice}

} {replaced.length > 0 && (awaitingRead ? (
That invitation was replaced since it was shown. Its actions return once the team has been read again.
) : (

That invitation was replaced since it was shown. The team now shows where it stands.

))}
{outstanding.map((member) => { const email = member.inviteEmail as string; const closed = busy || !decisionsOpen || isReplaced(member) || !member.invitationGeneration; const expiry = formatExpiry(member.inviteExpiresAt); return (

{email}

{/* The address is who will sign in; the registry name is who the NPI belongs to. Both show before access. */} {expiry &&

Expires {expiry}

}
{!confirmingFor(member) && (
{withdrawExternalInvite && member.invitationGeneration && ( )}
)}
{confirmingFor(member) && confirming?.kind === "resend" && (

Sending a new invitation replaces the old one — if they still have the first email, that previous link will stop working.

)} {confirmingFor(member) && confirming?.kind === "withdraw" && (

Withdrawing this invitation stops its link from working. You can invite this address again later.

)}
); })}
); }; export default PendingExternalInvites;