import { useEffect, useRef, useState } from "react"; import { UserPlus } from "lucide-react"; import { Link } from "react-router-dom"; import { Input } from "../ui/input"; import { Label } from "../ui/label"; import { useClinicianInvitationService, type AcceptOutcome, type InvitationPreview, } from "../../services/ClinicianInvitationService"; import { HttpError } from "../../services/HttpService"; import { relayServerFailure } from "../../utils/fetchFailureMessage"; import { answerInvitationToken, forgetInvitationToken as forgetToken, readInvitationToken as readToken } from "./invitationToken"; import "./ClinicianInvitationNotice.css"; /** * A practice's invitation to join as a clinician, answered by the signed-in account. Accepting never * assigns a seat; a different sign-in address is confirmed by a code sent to the invited inbox. */ // The server answers a withdrawn link and one somebody else used the same way, on purpose, so this names both. export const NO_LONGER_OPEN_MESSAGE = "This invitation is no longer open. The practice may have withdrawn it, or it has already been used. Ask the practice to send a new one."; const noLongerOpen = (failure: unknown): boolean => failure instanceof HttpError && (failure.body as { reason?: string } | undefined)?.reason === "invalid"; // The server finishes a join it already started when the same account asks again, so these are asked again. // A role written before its admission proof is visible is the same lag, so that held answer is asked again too. const STILL_JOINING = new Set(["binding-in-progress", "binding-uncertain", "admission-held:role-exists-unadmitted"]); const stillJoining = (outcome: AcceptOutcome): boolean => outcome.kind === "refused" && (STILL_JOINING.has(outcome.reason) || STILL_JOINING.has(`${outcome.reason}:${outcome.detail ?? ""}`)); export const FINISH_RETRY_EVERY_MS = 10_000; export const FINISH_RETRY_FOR_MS = 180_000; type Stage = | { kind: "loading" } | { kind: "ask"; preview: InvitationPreview } | { kind: "finishing"; preview: InvitationPreview } | { kind: "code"; preview: InvitationPreview; invitedEmail: string } | { kind: "done"; message: string; accepted?: boolean } | { kind: "closed" }; export interface InvitationCalls { previewInvitation: (token: string) => Promise; acceptInvitation: (token: string, code?: string, signal?: AbortSignal) => Promise; } interface NoticeProps { /** Answer this link with these calls instead of the stored link and the signed-in session. */ answering?: { token: string; calls: InvitationCalls; afterJoining: string; /** Once joined, with what the accept answered (the staff sign-in secret among it). */ onJoined?: (joined: Extract) => void; /** Once the invitation has been read, whatever it said: the host may then show its own way out beside it. */ onSettled?: () => void; /** Each stage the answer reaches once read; "closed" is Not now. `accepted` marks a link someone already used to join. */ onStage?: (kind: "ask" | "code" | "done" | "closed", accepted: boolean) => void; /** Offer the staff sign-in on a link someone already used to join, and nowhere else. */ signInWhenUsed?: boolean; }; } export const ClinicianInvitationNotice = ({ answering }: NoticeProps = {}) => { const own = useClinicianInvitationService(); const { previewInvitation, acceptInvitation } = answering?.calls ?? own; const [token] = useState(() => answering?.token ?? readToken()); const [stage, setStage] = useState(token ? { kind: "loading" } : { kind: "closed" }); const [code, setCode] = useState(""); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const mounted = useRef(true); // On unmount: the pending re-ask is cancelled and the accept in flight is aborted. const retryTimer = useRef | null>(null); const endWait = useRef<(() => void) | null>(null); const inFlight = useRef(null); useEffect(() => { mounted.current = true; return () => { mounted.current = false; if (retryTimer.current) clearTimeout(retryTimer.current); endWait.current?.(); inFlight.current?.abort(); }; }, []); useEffect(() => { if (!token) return; let active = true; previewInvitation(token) .then((preview) => { if (!active) return; setStage(preview.status === "accepted" ? { kind: "done", message: `This invitation to ${preview.organizationName} has already been accepted.`, accepted: true } : { kind: "ask", preview }); }) .catch((failure) => { if (!active) return; forgetToken(); setStage({ kind: "done", message: noLongerOpen(failure) ? NO_LONGER_OPEN_MESSAGE : relayServerFailure(failure, "This invitation link is not valid."), }); }); return () => { active = false; }; }, [token, previewInvitation]); const settled = stage.kind !== "loading"; const onSettled = answering?.onSettled; useEffect(() => { if (settled) onSettled?.(); }, [settled, onSettled]); const onStage = answering?.onStage; useEffect(() => { if (stage.kind !== "loading" && stage.kind !== "finishing") onStage?.(stage.kind, stage.kind === "done" && stage.accepted === true); }, [stage, onStage]); if (stage.kind === "closed" || stage.kind === "loading" || !token) return null; const accept = async (preview: InvitationPreview, withCode?: string) => { setBusy(true); setError(null); let finishing = false; const controller = new AbortController(); inFlight.current = controller; const waitToRetry = () => new Promise((resume) => { endWait.current = resume; retryTimer.current = setTimeout(resume, FINISH_RETRY_EVERY_MS); }); try { const giveUpAt = Date.now() + FINISH_RETRY_FOR_MS; let outcome = await acceptInvitation(token, withCode, controller.signal); if (!mounted.current) return; while (stillJoining(outcome) && Date.now() + FINISH_RETRY_EVERY_MS <= giveUpAt) { finishing = true; setStage({ kind: "finishing", preview }); await waitToRetry(); if (!mounted.current) return; outcome = await acceptInvitation(token, withCode, controller.signal); if (!mounted.current) return; } if (outcome.kind === "accepted") { answerInvitationToken(token); setStage({ kind: "done", message: preview.kind === "staff" ? `You joined ${preview.organizationName}. ${answering?.afterJoining ?? "Sign out and back in to open it."}` : `You joined ${preview.organizationName} as a clinician. ${answering?.afterJoining ?? "Sign out and back in to open it."} ` + "The practice assigns your clinician seat next.", }); answering?.onJoined?.(outcome); } else if (outcome.kind === "code-required") { setStage({ kind: "code", preview, invitedEmail: outcome.invitedEmail }); } else if (outcome.reason === "code-incorrect" || outcome.reason === "code-expired" || outcome.reason === "code-attempts-exceeded") { setError(outcome.message); } else if (stillJoining(outcome)) { // The join may still finish, so the link stays for the next visit. setStage({ kind: "done", message: outcome.message }); } else { forgetToken(); setStage({ kind: "done", message: outcome.reason === "invalid" ? NO_LONGER_OPEN_MESSAGE : outcome.message }); } } catch (failure) { const message = relayServerFailure(failure, "That could not be completed. Please try again."); if (!mounted.current) return; if (finishing) setStage({ kind: "done", message }); else setError(message); } finally { if (inFlight.current === controller) inFlight.current = null; endWait.current = null; retryTimer.current = null; if (mounted.current) setBusy(false); } }; const decline = () => { forgetToken(); setStage({ kind: "closed" }); }; return (
); }; export default ClinicianInvitationNotice;