import { useEffect, useRef, useState, type FormEvent } from 'react'; import { Link } from 'react-router-dom'; import { Heading } from '../../components/ui/heading'; import { InputControl } from '../../components/ui/input-control'; import { Button } from '../../components/ui/button'; import { DismissibleAlert } from '../../components/ui/dismissible-alert'; import PracticeDoorCard from '../LoginPage/PracticeDoorCard'; import { useOptionalAuthServiceContext } from '../../services/AuthService'; import { AKASHIC_SUPPORT_ADDRESS } from '../../config/support'; import { commitPhoneRecovery, requestPhoneRecovery, verifyPhoneRecovery, type PhoneRecoveryOutcome, type PhoneVerifyReceipt, } from '../../services/phoneRecovery'; type Step = 'addresses' | 'phone-code' | 'email-code' | 'done'; type Call = 'request' | 'verify' | 'resend' | 'commit'; type Pointer = { operationId: string; step: 'phone-code' | 'email-code' }; type Ticket = { operationId: string; step: Step; attempt: number }; const POINTER_KEY = 'akashic.phoneRecovery'; const PROBE_KEY = 'akashic.phoneRecovery.probe'; const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; const UNREACHABLE = 'We could not reach Akashic. Check your connection and try again.'; const UNKNOWN = 'We could not confirm that went through. Check asks again with the same code.'; const NO_STORAGE = 'Recovery cannot safely start in this browser. Try another browser or contact support.'; const REQUESTED = 'If the details match an account with a verified mobile, a code is on its way.'; const MAY_ALREADY_WORK = 'Signing in with your new address may already work.'; // The tab keeps only which operation and which step; addresses and codes never leave memory. const readPointer = (): Pointer | null => { try { const parsed: unknown = JSON.parse(sessionStorage.getItem(POINTER_KEY) ?? 'null'); const { operationId, step } = (parsed ?? {}) as Partial; return typeof operationId === 'string' && UUID.test(operationId) && (step === 'phone-code' || step === 'email-code') ? { operationId, step } : null; } catch { return null; } }; const writePointer = (pointer: Pointer | null) => { try { if (pointer) sessionStorage.setItem(POINTER_KEY, JSON.stringify({ operationId: pointer.operationId, step: pointer.step })); else sessionStorage.removeItem(POINTER_KEY); } catch { /* checked before the first request; a later failure only costs the resume */ } }; const storageWorks = () => { try { sessionStorage.setItem(PROBE_KEY, '1'); sessionStorage.removeItem(PROBE_KEY); return true; } catch { return false; } }; const emailCodeNotice = (receipt: PhoneVerifyReceipt) => receipt.emailCode === 'unconfirmed' ? `We could not confirm a code is on its way to ${receipt.maskedNewEmail}. If none arrives, use Send a new code.` : `A code is on its way to ${receipt.maskedNewEmail}.`; // For a patient who has lost their login email: prove the mobile they verified, then the new address. export const PhoneRecoveryPage = () => { // The same door as sign-in; the route runs outside the org check, so the practice is whatever the // app already knows, and the card stands without a header when it knows none. const organization = useOptionalAuthServiceContext()?.wpOrganization.current; const [resumed] = useState(readPointer); const [step, setStep] = useState(resumed?.step ?? 'addresses'); const [operationId, setOperationId] = useState(() => resumed?.operationId ?? crypto.randomUUID()); const [email, setEmail] = useState(''); const [newEmail, setNewEmail] = useState(''); const [phoneCode, setPhoneCode] = useState(''); const [emailCode, setEmailCode] = useState(''); const [resendOpen, setResendOpen] = useState(false); const [notice, setNotice] = useState(resumed?.step === 'email-code' ? 'Enter the code from the email to your new address.' : ''); const [error, setError] = useState(''); const [unknown, setUnknown] = useState(null); const [mayAlreadyWork, setMayAlreadyWork] = useState(false); const [expired, setExpired] = useState(false); const [busy, setBusy] = useState(false); // Each call is owned by {operationId, step, attempt}; an answer whose owner is no longer current, // or that lands after unmount, changes nothing: no state, no storage. const mounted = useRef(true); const owner = useRef(null); const attempts = useRef(0); const sentFor = useRef(null); // The texted code of the attempt left by Start again: the one proof that may replace it before its lease ends. const restartProof = useRef(null); useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []); const run = async ( id: string, call: Call, send: () => Promise>, onOk: (data: T) => void, onRefused?: (status: number, message: string) => void, ) => { const ticket: Ticket = { operationId: id, step, attempt: ++attempts.current }; owner.current = ticket; setBusy(true); setError(''); setUnknown(null); const outcome = await send(); if (!mounted.current || owner.current !== ticket) return; owner.current = null; setBusy(false); if (outcome.kind === 'ok') onOk(outcome.data); else if (outcome.kind === 'unknown') { setUnknown(call); setError(UNKNOWN); if (step === 'email-code') setMayAlreadyWork(true); } else if (outcome.kind === 'refused') { setError(outcome.message); onRefused?.(outcome.status, outcome.message); } else setError(UNREACHABLE); }; const dispatch = (call: Call) => { if (busy) return; if (call === 'request') { if (!storageWorks()) { setError(NO_STORAGE); return; } // The same addresses repeat the same operation; changed addresses are a new one. const intent = JSON.stringify([email.trim().toLowerCase(), newEmail.trim().toLowerCase()]); const id = sentFor.current === null || sentFor.current === intent ? operationId : crypto.randomUUID(); if (id !== operationId) setOperationId(id); sentFor.current = intent; const phoneCodeForRestart = restartProof.current; void run(id, call, () => requestPhoneRecovery({ email, newEmail, operationId: id, ...(phoneCodeForRestart ? { phoneCode: phoneCodeForRestart } : {}), }), () => { restartProof.current = null; writePointer({ operationId: id, step: 'phone-code' }); setNotice(REQUESTED); setStep('phone-code'); }, (status) => { if (status !== 400) return; sentFor.current = null; setOperationId(crypto.randomUUID()); }); } else if (call === 'verify') { void run(operationId, call, () => verifyPhoneRecovery({ operationId, code: phoneCode }), (data) => { writePointer({ operationId, step: 'email-code' }); setNotice(emailCodeNotice(data)); setStep('email-code'); }); } else if (call === 'resend') { setExpired(false); void run(operationId, call, () => verifyPhoneRecovery({ operationId, code: phoneCode, resend: true }), (data) => { setNotice(emailCodeNotice(data)); setResendOpen(false); setEmailCode(''); }, (status) => { if (status === 401) { setError(''); setExpired(true); } }); } else { void run(operationId, call, () => commitPhoneRecovery({ operationId, code: emailCode }), (data) => { if (data.status === 'checking') { setUnknown('commit'); setError('We are still checking the change. Check asks again with the same code.'); setMayAlreadyWork(true); return; } writePointer(null); setNotice(`Your sign-in email is now ${data.maskedNewEmail}.`); setStep('done'); }); } }; const startAgain = () => { restartProof.current = phoneCode.trim() || null; owner.current = null; sentFor.current = null; writePointer(null); setOperationId(crypto.randomUUID()); setStep('addresses'); setEmail(''); setNewEmail(''); setPhoneCode(''); setEmailCode(''); setResendOpen(false); setNotice(''); setError(''); setUnknown(null); setMayAlreadyWork(false); setExpired(false); setBusy(false); }; const onSubmit = (event: FormEvent) => { event.preventDefault(); dispatch(step === 'addresses' ? 'request' : step === 'phone-code' ? 'verify' : 'commit'); }; const onResend = (event: FormEvent) => { event.preventDefault(); dispatch('resend'); }; if (step === 'done') { return (
{notice} Sign in
); } return (
Recover your account with your phone {step === 'addresses' && (

If you lost access to the email you sign in with, a mobile you verified yourself can move your sign-in to a new address.

)} {notice && step !== 'addresses' &&

{notice}

} {error && {error}} {mayAlreadyWork && step === 'email-code' &&

{MAY_ALREADY_WORK}

} {step === 'addresses' ? ( <> setEmail(value ?? '')} /> setNewEmail(value ?? '')} /> ) : step === 'phone-code' ? ( setPhoneCode(value ?? '')} /> ) : ( setEmailCode(value ?? '')} /> )}
Back to sign in
{step !== 'addresses' && } {step === 'email-code' && !resendOpen && ( )} {unknown && unknown !== 'resend' ? : }
{step === 'email-code' && resendOpen && (

A new emailed code needs the code we texted to your mobile for this recovery.

{expired && (
Your texted code is no longer valid.

You can enter it again, or use Start again to begin with your addresses. If the change already went through, Sign in with your new address. If you are still shut out, write to {AKASHIC_SUPPORT_ADDRESS}.

)} setPhoneCode(value ?? '')} />
{unknown === 'resend' ? : }
)}
); }; export default PhoneRecoveryPage;