import { useEffect, useRef, useState } from "react"; import { KeyRound } from "lucide-react"; import { toast } from "sonner"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "../../components/ui/dialog"; import { Button } from "../../components/ui/button"; import { Input } from "../../components/ui/input"; import { useLoginEmailChangeService } from "../../services"; import { HttpError } from "../../services/HttpService.tsx"; import type { LoginEmailChangeStatusResponse, LoginEmailChangeVerifyNewResponse, LoginEmailStage, LoginEmailTransport, } from "../../services/LoginEmailChangeService.tsx"; import { onViewerChange, viewerEpoch } from "../../utils/writeThroughHold"; // The three server steps map to three dialog steps; 'done' is the terminal confirmation. // email -> POST request (code emailed to the CURRENT address) // verify-current -> POST verify-current (proves control of the current address - the gate) // verify-new -> POST verify-new (confirms the new address, server commits) // checking: the change was admitted and its outcome is not yet confirmed; other: another change of this patient is open. type Step = "email" | "verify-current" | "verify-new" | "checking" | "other" | "ended" | "done"; // The login-email endpoints, like DuplicateMatch, return curated user-safe messages (no account // enumeration, no internal detail), so showing the server's text is intentional and scoped here. const serverMessage = (err: unknown): string | undefined => { if (err instanceof HttpError && err.message.trim().length > 0) { return err.message.trim(); } return undefined; }; const bodyOf = (err: unknown): Record => err instanceof HttpError && err.body && typeof err.body === "object" ? (err.body as Record) : {}; const codeOf = (err: unknown): string | undefined => { const code = bodyOf(err).code; return typeof code === "string" ? code : undefined; }; // Refusals after which this opening has nothing left to do: the change cannot go on from here. const ENDING = new Set(["NO_LOGIN_EMAIL", "ADDRESS_IN_USE", "EXPIRED_UNSENT", "CHECKING_CASE", "ACCOUNT_MISMATCH"]); // The change moved on under this opening; where it stands is read, never assumed. const MOVED = new Set(["STEP_UNAVAILABLE", "CHANGE_COMPLETE", "CHANGE_COMPLETING"]); // Mirror of the server's deliberately-permissive shape check: reject only what obviously is not an // address, and let Cognito be the authority on deliverability. const looksLikeEmail = (value: string): boolean => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim()); const retiredError = () => new DOMException("This change dialog was closed.", "AbortError"); /** One showing of the dialog to one viewer. Closing retires it for good; a reopen is always a new one. */ type Opening = { epoch: number; operationId: string; retired: boolean; controller: AbortController }; type Attempt = { opening: Opening; proof: boolean }; /** What a closed opening left on the server: a cancel still owed, or a change whose outcome is read next time. */ type Obligation = { epoch: number; pendingChangeId: string; cancel: boolean }; export type LoginEmailChanged = { patientId: string; epoch: number }; type Props = { open: boolean; onOpenChange: (open: boolean) => void; // The address the account currently signs in with, for display only. currentLoginEmail: string | undefined; // The patient whose sign-in email this is; handed back with a committed change. patientId: string; // Called after a committed change so the parent can refresh the displayed Login Email for that viewer. onChanged?: (changed: LoginEmailChanged) => void; }; const ChangeLoginEmailDialog = ({ open, onOpenChange, currentLoginEmail, patientId, onChanged }: Props) => { const { requestChange, verifyCurrent, verifyNew, cancelChange, resendCode, changeStatus } = useLoginEmailChangeService(); const [step, setStep] = useState("email"); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const [newEmail, setNewEmail] = useState(""); const [code, setCode] = useState(""); const [pendingChangeId, setPendingChangeId] = useState(""); const [maskedCurrentEmail, setMaskedCurrentEmail] = useState(""); const [maskedNewEmail, setMaskedNewEmail] = useState(""); const [otherChangeId, setOtherChangeId] = useState(""); const openingRef = useRef(null); const attemptRef = useRef(null); const heldRef = useRef(null); // The change this opening is on, as last answered; read synchronously when the dialog closes. const changeRef = useRef<{ pendingChangeId: string; stage: LoginEmailStage } | null>(null); const latest = useRef({ onOpenChange, onChanged, patientId }); latest.current = { onOpenChange, onChanged, patientId }; const owns = (opening: Opening) => openingRef.current === opening && !opening.retired && viewerEpoch() === opening.epoch; const transportOf = (opening: Opening): LoginEmailTransport => ({ admit: () => { if (!owns(opening)) throw retiredError(); }, signal: opening.controller.signal, }); const resetView = () => { setStep("email"); setBusy(false); setError(null); setNewEmail(""); setCode(""); setPendingChangeId(""); setMaskedCurrentEmail(""); setMaskedNewEmail(""); setOtherChangeId(""); changeRef.current = null; }; const retire = (opening: Opening) => { opening.retired = true; opening.controller.abort(); if (openingRef.current === opening) openingRef.current = null; if (attemptRef.current?.opening === opening) attemptRef.current = null; }; const adopt = (id: string, stage: LoginEmailStage) => { changeRef.current = { pendingChangeId: id, stage }; setPendingChangeId(id); }; const changed = (opening: Opening, tagSynced: boolean) => { changeRef.current = null; setStep("done"); if (tagSynced) { toast.success("Your login email was changed."); } else { // Committed in Cognito; only the display tag lagged. Not an error for the patient. toast.success("Your login email was changed. It may take a moment to appear here."); } latest.current.onChanged?.({ patientId: latest.current.patientId, epoch: opening.epoch }); }; const show = (opening: Opening, status: LoginEmailChangeStatusResponse) => { setCode(""); switch (status.stage) { case "pending-current": adopt(status.pendingChangeId, status.stage); setMaskedCurrentEmail(status.maskedCurrentEmail ?? ""); setStep("verify-current"); return; case "pending-new": case "committing": adopt(status.pendingChangeId, status.stage); setMaskedNewEmail(status.maskedNewEmail ?? ""); setStep(status.stage === "committing" || status.checking ? "checking" : "verify-new"); return; case "committed": setMaskedNewEmail(status.maskedNewEmail ?? ""); changed(opening, status.tagSynced !== false); return; default: changeRef.current = null; setPendingChangeId(""); setStep("email"); } }; const finish = (opening: Opening, res: LoginEmailChangeVerifyNewResponse) => { setMaskedNewEmail(res.maskedNewEmail); // A pending receipt is still being recorded by the server; the account itself has changed. changed(opening, res.receipt === "pending" ? false : res.tagSynced !== false); }; /** Runs one attempt in the current opening. Its answer, refusal and settling are written only while both still own the dialog. */ const run = async ( proof: boolean, fallback: string, work: (opening: Opening, transport: LoginEmailTransport, mine: () => boolean) => Promise, concerning?: string, ) => { const opening = openingRef.current; if (!opening || !owns(opening) || attemptRef.current) return; const attempt: Attempt = { opening, proof }; attemptRef.current = attempt; const mine = () => owns(opening) && attemptRef.current === attempt; const transport = transportOf(opening); setError(null); setBusy(true); try { await work(opening, transport, mine); } catch (err) { if (mine()) await refused(err, fallback, opening, transport, mine, concerning); } finally { if (attemptRef.current === attempt) attemptRef.current = null; if (owns(opening) && !attemptRef.current) setBusy(false); } }; // No refusal cancels or restarts anything: each names what happened, and a moved change is read back. const refused = async ( err: unknown, fallback: string, opening: Opening, transport: LoginEmailTransport, mine: () => boolean, concerning?: string, ) => { const code = codeOf(err); const body = bodyOf(err); const message = serverMessage(err) ?? fallback; if (code && ENDING.has(code)) { changeRef.current = null; setStep("ended"); setError(message); return; } if (code === "CHECKING") { if (changeRef.current) changeRef.current = { ...changeRef.current, stage: "committing" }; setStep("checking"); setError(message); return; } if (code === "CHANGE_IN_PROGRESS") { if (typeof body.pendingChangeId === "string") { setOtherChangeId(body.pendingChangeId); setStep("other"); } setError(message); return; } const id = concerning ?? changeRef.current?.pendingChangeId; if (code && MOVED.has(code) && id) { try { const status = await changeStatus(id, transport); if (!mine()) return; show(opening, status); if (status.stage === "none") setError(message); } catch { if (mine()) setError(message); } return; } setError(message); }; /** A closed opening's cancel, owned by no view: its answer only settles the obligation it was sent for. */ const sendDetachedCancel = (obligation: Obligation) => { const controller = new AbortController(); const transport: LoginEmailTransport = { admit: () => { if (viewerEpoch() !== obligation.epoch) throw retiredError(); }, signal: controller.signal, }; cancelChange(obligation.pendingChangeId, transport).then( () => { if (heldRef.current === obligation) heldRef.current = null; }, (err: unknown) => { if (heldRef.current !== obligation) return; const code = codeOf(err); if (err instanceof HttpError && err.status === 404) heldRef.current = null; else if (code === "CHANGE_COMPLETING" || code === "CHANGE_COMPLETE") heldRef.current = { ...obligation, cancel: false }; }, ); }; const closeOpening = () => { const opening = openingRef.current; if (!opening) return; const change = changeRef.current; const proofInFlight = attemptRef.current?.opening === opening && attemptRef.current.proof; retire(opening); resetView(); if (!change || change.stage === "committed") return; const precommit = change.stage === "pending-current" || change.stage === "pending-new"; const obligation: Obligation = { epoch: opening.epoch, pendingChangeId: change.pendingChangeId, cancel: precommit && !proofInFlight }; heldRef.current = obligation; if (obligation.cancel) sendDetachedCancel(obligation); }; const startOpening = () => { const opening: Opening = { epoch: viewerEpoch(), operationId: crypto.randomUUID(), retired: false, controller: new AbortController() }; openingRef.current = opening; resetView(); const held = heldRef.current; if (!held) return; if (held.epoch !== opening.epoch) { heldRef.current = null; return; } // The same viewer's earlier change: show where it really stands before anything else is sent. void run(false, "We could not check your earlier change. Please try again.", async (o, transport, mine) => { const status = await changeStatus(held.pendingChangeId, transport); if (!mine()) return; if (heldRef.current === held) heldRef.current = null; show(o, status); }); }; useEffect(() => { if (open && !openingRef.current) startOpening(); if (!open && openingRef.current) closeOpening(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [open]); useEffect(() => onViewerChange(() => { const opening = openingRef.current; heldRef.current = null; if (!opening) return; retire(opening); resetView(); latest.current.onOpenChange(false); }), []); useEffect(() => () => { if (openingRef.current) retire(openingRef.current); }, []); const handleOpenChange = (next: boolean) => { if (!next) closeOpening(); onOpenChange(next); }; const submitEmail = () => { setError(null); const trimmed = newEmail.trim(); if (!looksLikeEmail(trimmed)) { setError("Enter a valid email address."); return; } if (currentLoginEmail && trimmed.toLowerCase() === currentLoginEmail.toLowerCase()) { setError("That is already your login email."); return; } void run(false, "Could not start the change. Please try again.", async (opening, transport, mine) => { const res = await requestChange(trimmed, opening.operationId, transport); if (!mine()) return; if (res.stage && res.stage !== "pending-current") { const status = await changeStatus(res.pendingChangeId, transport); if (mine()) show(opening, status); return; } adopt(res.pendingChangeId, "pending-current"); setMaskedCurrentEmail(res.maskedCurrentEmail); setCode(""); setStep("verify-current"); }); }; const submitCurrentCode = () => { setError(null); if (code.trim().length === 0) return; const id = pendingChangeId; void run(true, "That code could not be verified. Please try again.", async (_opening, transport, mine) => { const res = await verifyCurrent(id, code.trim(), transport); if (!mine()) return; adopt(id, "pending-new"); setMaskedNewEmail(res.maskedNewEmail); setCode(""); setStep("verify-new"); }); }; const submitNewCode = () => { setError(null); if (code.trim().length === 0) return; const id = pendingChangeId; void run(true, "That code could not be verified. Please try again.", async (opening, transport, mine) => { const res = await verifyNew(id, code.trim(), transport); if (mine()) finish(opening, res); }); }; // The change was admitted and its outcome is not yet confirmed: continue it, with no code and no new change. const checkAgain = () => { const id = pendingChangeId; void run(true, "We could not confirm the change yet. Please try again.", async (opening, transport, mine) => { const res = await verifyNew(id, undefined, transport); if (mine()) finish(opening, res); }); }; // Another copy of the code, on whichever step is asking. The request carries the pendingChangeId and // NOTHING else: which address receives the code is decided server-side from the record's status, so // this can never be talked into mailing the current-address code to the new address. const resend = () => { const id = pendingChangeId; void run(false, "Could not send another code. Please try again.", async (opening, transport, mine) => { const res = await resendCode(id, transport); if (!mine()) return; // Clear the superseded code: leaving it in the box invites resubmitting the one that just // stopped working, which is the loop this control exists to break. setCode(""); // Labelled by the stage the server answered for, never by the step this view was on. if (res.stage === "pending-current") { adopt(id, res.stage); setMaskedCurrentEmail(res.maskedEmail); setStep("verify-current"); } else if (res.stage === "pending-new") { adopt(id, res.stage); setMaskedNewEmail(res.maskedEmail); setStep("verify-new"); } else { const status = await changeStatus(id, transport); if (mine()) show(opening, status); } }); }; const continueOther = () => { const id = otherChangeId; void run(false, "We could not open that change. Please try again.", async (opening, transport, mine) => { const status = await changeStatus(id, transport); if (!mine()) return; setOtherChangeId(""); show(opening, status); }, id); }; // A deliberate cancel of exactly the change the server named; nothing else is cancelled. const cancelOther = () => { const id = otherChangeId; void run(false, "We could not cancel that change. Please try again.", async (_opening, transport, mine) => { await cancelChange(id, transport); if (!mine()) return; setOtherChangeId(""); setStep("email"); toast.success("The earlier change was cancelled. You can start a new one."); }, id); }; const codeStep = step === "verify-current" || step === "verify-new"; return ( Change Login Email {step === "email" && "Enter the new email address you want to sign in with."} {step === "verify-current" && `We sent a code to your current login email (${maskedCurrentEmail}). Enter it to confirm it's you.`} {step === "verify-new" && `We sent a code to ${maskedNewEmail}. Enter it to finish the change.`} {step === "checking" && "We are confirming your new login email. This can take a moment."} {step === "other" && "A login email change is already in progress on your account."} {step === "ended" && "This change cannot continue."} {step === "done" && "Your login email has been updated."} {error && (

{error}

)} {step === "email" && ( setNewEmail(e.target.value)} onKeyDown={(e) => e.key === "Enter" && !busy && submitEmail()} aria-label="New login email" /> )} {codeStep && ( setCode(e.target.value)} onKeyDown={(e) => { if (e.key !== "Enter" || busy) return; if (step === "verify-current") submitCurrentCode(); else submitNewCode(); }} aria-label="Verification code" /> )} {step === "done" && (

You'll use {maskedNewEmail} to sign in from now on. Your previous address has been notified and can no longer be used to sign in.

)} {step === "done" || step === "ended" ? ( ) : ( <> {step === "email" && ( )} {/* THE RESEND MUST BE ON SCREEN WHILE A CODE IS OUTSTANDING, on both verify steps, because either can be the one the patient is stranded on. */} {codeStep && ( )} {step === "verify-current" && ( )} {step === "verify-new" && ( )} {step === "checking" && ( )} {step === "other" && ( <> )} )}
); }; export default ChangeLoginEmailDialog;