import { useCallback, useEffect, useRef, useState } from 'react'; import { AlertTriangle, CheckCircle2, Loader2, Lock, ShieldCheck } from 'lucide-react'; import { Card } from '../../components/ui/card'; import { Button } from '../../components/ui/button'; import { useCredentialRecoveryService } from '../../services/CredentialRecoveryService.tsx'; import { HttpError } from '../../services/HttpService.tsx'; import { forgetUnlockToken, resolveUnlockToken } from '../../utils/credentialRecoveryLink'; import { hasProofingReturnMarker } from '../../utils/proofingFlow'; import { withoutTransportWrapper } from '../../utils/fetchFailureMessage'; // D4, Task 12 - THE ONLY DOOR BACK INTO A LOCKED ACCOUNT // (`/credential-unlock?u=`). // // MUST be reachable WITHOUT a session. revert.ts disabled this patient's Cognito user; that is the // premise of the whole flow. Mounted in App.tsx alongside /login and /accept-invitation, OUTSIDE // RequireAuth. Inside the authed shell it would redirect to /login - and that failure is silent, // because the redirect looks like a working app to everyone except the locked-out patient. // // This page satisfies the six client requirements written into // `handlers/CredentialRecovery/unlock.ts`'s header. That file is canonical; the mapping is: // // 1. RETRY A 403 OVER A MINUTES-LONG BUDGET. Stripe redirects the browser the moment the document is // SUBMITTED, but `verifiedAt` is written by the WEBHOOK - seconds usually, several minutes when a // document goes to closer inspection - so the first attempt after a genuine verification commonly // 403s. See UNLOCK_RETRY_BUDGET_MS and runUnlockLoop. // 2. TERMINAL STATE IS NOT THE REFUSAL. `state === 'stalled'` offers "Try again" and "contact your // practice". The server's 403 message is deliberately uniform (it must not be an oracle for which // accounts are locked), so it reads as "you did it wrong" to someone who did nothing wrong. It is // never rendered on this page - the one place the "surface the server's message" habit is // explicitly overridden by the server's own instructions. proof-session's refusal IS surfaced, // because that one is informative. // // - AND THE OTHER HALF OF #2, WHICH THIS PAGE ORIGINALLY GOT WRONG. Not rendering the // server's sentence is only half the obligation. Because the refusal is uniform across "locked and // unverified" and "no such token", THIS PAGE CANNOT KNOW WHICH HAPPENED - so it must not narrate // either one. It used to head the refusal screen "Your account is locked" and explain that // "someone reported that the sign-in email had been changed without permission": a specific, // alarming, unverifiable claim shown to anyone who merely mistyped or re-used a stale link. Found // in a browser (card-086 UI spec, U2, 2026-08-05) with a token that never existed. Substituting // our own copy for the server's silence is the same defect as substituting it for the server's // words - the screen now states only what is known and phrases the lock as a CONDITION. // Note the spec's own U2 remedy ("the exact server sentence must appear") is WRONG and was // corrected: it contradicts this very contract. Both halves are pinned by tests. // 3. THE TOKEN OUTLIVES THE TAB. utils/credentialRecoveryLink.ts (localStorage, not sessionStorage). // 4. THE RETURN MARKER IS A HINT, NOT A GATE. Holding a token is the whole precondition; the marker // only decides whether to start the sustained poll immediately or probe once. unlock.ts fails // closed on every path, so an attempt that is too early costs nothing but a 403. // 5. STOP ON A 200. runUnlockLoop returns on the first resolve. The unlock clears the lock and // revokes the token, so a retry after success returns the same uniform 403 - a client that kept // polling would end up telling an ALREADY-UNLOCKED patient that verification is required. // 6. NAME THE ADDRESS. The 200 body is `{ status: 'unlocked' }` and carries no address on purpose. // Their login email was changed and then reverted, so the success copy says to use the ORIGINAL // one - the address this notice was sent to. // // THE `proofing_return=1` MARKER IS DELIBERATELY NOT STRIPPED, unlike hooks/useProofingReturn.tsx. // That hook strips it so a refresh mid-poll doesn't restart the poll. Here restarting the poll is the // GOOD outcome and dropping to the "still locked" screen is the bad one, so the trade runs the other // way. Same detector (utils/proofingFlow.ts) - not a second resume mechanism. /** ~37 attempts over five minutes. Long enough for a Stripe document that goes to manual review. */ export const UNLOCK_POLL_INTERVAL_MS = 8_000; /** * MINUTES, NOT SECONDS - unlock.ts says so in as many words. A sixty-second budget shows * "identity verification is required" to a patient who is merely mid-review, which is the exact * outcome the retry exists to prevent. Every 403 with a live token also writes an audit event * server-side, so the interval is spaced rather than tight. */ export const UNLOCK_RETRY_BUDGET_MS = 300_000; type State = | 'checking' // the opening probe; we do not yet know which screen the patient needs // 🚨 'refused', NOT 'locked' β€” card 157. The server answered no; it did NOT say the account is // locked, and it deliberately never will. Naming this state after the conclusion we cannot draw is // what produced the copy that drew it. Keep the name honest and the copy follows. | 'refused' // probed and refused - offer the identity check, without claiming to know why | 'starting' // creating the Stripe session; the browser is about to leave | 'verifying' // the sustained poll (contract #1) | 'unlocked' // terminal, success | 'stalled' // terminal, budget expired (contract #2) | 'unreachable' // we never got an answer at all - NOT a refusal | 'no-token'; /** * Strip HttpService's `Request failed: ` wrapper so the server's own sentence reads plainly. * * ONLY EVER CALLED ON AN HttpError. A dropped connection throws a bare `TypeError: Failed to * fetch`, which has no such prefix and would sail through this function unchanged - rendering * "Failed to fetch" under a red alert to a frightened patient, as though the server had said it. * Callers branch on `instanceof HttpError` first; see the 'unreachable' state. */ const describeRefusal = (error: HttpError): string => withoutTransportWrapper(error.message); interface CredentialUnlockPageProps { /** Overridable for tests only; production mounts use the exported defaults. */ pollIntervalMs?: number; retryBudgetMs?: number; } export const CredentialUnlockPage = ({ pollIntervalMs = UNLOCK_POLL_INTERVAL_MS, retryBudgetMs = UNLOCK_RETRY_BUDGET_MS, }: CredentialUnlockPageProps = {}) => { const { unlockAccount, startProofSession } = useCredentialRecoveryService(); const [state, setState] = useState('checking'); const [token, setToken] = useState(); const [proofingError, setProofingError] = useState(''); const startedRef = useRef(false); // RESET ON MOUNT, NOT JUST SET ON UNMOUNT - and phrased positively so the reset is obviously // load-bearing rather than something to "simplify" away. // // src/main.tsx renders inside , and WordPress serves React's DEVELOPMENT build // whenever SCRIPT_DEBUG is on (the normal state of a local or staging install - precisely where // this flow gets smoke-tested). React then double-invokes effects, mount cleanup mount, while // PRESERVING refs. An unmount-only flag is therefore still set when the second mount runs: the // start effect below bails on `startedRef`, nothing clears the flag, and every guard here swallows // its state update. The page hangs on "Checking your account - just a moment" forever, on the one // page whose failure means permanent lockout. Assigning on mount is what makes the second mount // recover. hooks/useProofingReturn.tsx dodges this with an effect-scoped `let cancelled`; that // shape does not fit here because runUnlockLoop is also invoked from button handlers, which live // outside any effect. Covered by the StrictMode tests. const mountedRef = useRef(true); useEffect(() => { mountedRef.current = true; return () => { mountedRef.current = false; }; }, []); /** * POST until it succeeds or the budget runs out. * * Contract #1 and #5 both live here. A refusal is never rendered from inside the loop - the * interim "we're verifying your identity" state covers the whole window, and only the budget * expiring moves the patient to the terminal screen. */ const runUnlockLoop = useCallback(async (activeToken: string) => { setState('verifying'); const deadline = Date.now() + retryBudgetMs; for (;;) { if (!mountedRef.current) return; try { await unlockAccount(activeToken); if (!mountedRef.current) return; // Contract #5: a 200 is final. Drop the stored token so a later visit to this page // cannot spin for five minutes against a credential that has already been revoked. forgetUnlockToken(); setState('unlocked'); return; } catch { if (!mountedRef.current) return; if (Date.now() + pollIntervalMs >= deadline) { setState('stalled'); return; } await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)); } } }, [unlockAccount, pollIntervalMs, retryBudgetMs]); /** * One attempt, for the ordinary "just opened the emailed link" arrival. * * It runs unconditionally (contract #4) because it is the whole answer for a patient who * re-opens the link hours after the webhook landed. When it refuses we show the REFUSED screen - * not the server's sentence, not the "we're verifying" copy (a lie to someone who has not started * yet), and not a claim about a lock we cannot confirm. */ const probeOnce = useCallback(async (activeToken: string) => { try { await unlockAccount(activeToken); if (!mountedRef.current) return; forgetUnlockToken(); setState('unlocked'); } catch (error: unknown) { if (!mountedRef.current) return; // A DROPPED CONNECTION IS NOT A REFUSAL. Only an HttpError means the server actually // answered; a transport failure means we learned nothing at all, and telling this patient // their account is locked (or worse, showing them "Failed to fetch") would be inventing a // verdict we never received. Same distinction AcceptCareTeamInvitePage draws. setState(error instanceof HttpError ? 'refused' : 'unreachable'); } }, [unlockAccount]); useEffect(() => { if (startedRef.current) return; startedRef.current = true; // Read from the raw href, never useLocation(): this is a HashRouter SPA, and `?u=` is // authoritative and re-persisted on every visit. See utils/credentialRecoveryLink.ts. const resolved = resolveUnlockToken(window.location.href); if (!resolved) { setState('no-token'); return; } setToken(resolved); if (hasProofingReturnMarker(window.location.href)) { void runUnlockLoop(resolved); } else { void probeOnce(resolved); } // Runs once on mount. // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // The stalled screen's "Verify my identity" button cannot guard itself with // `disabled={state === 'starting'}` the way the refused screen's copy does: inside that branch // `state` is narrowed to 'stalled', so the check is always false. Nothing else stopped a second // click landing before the browser leaves for Stripe, which would open two proofing sessions. // Cleared on every failure path so a retry stays possible; on success the page navigates away. const proofingInFlightRef = useRef(false); const beginProofing = async () => { if (!token) return; if (proofingInFlightRef.current) return; proofingInFlightRef.current = true; setProofingError(''); setState('starting'); try { const { url } = await startProofSession(token); // Matches ProofingService.startVerification - the browser leaves here and Stripe returns // it to the SAME origin (the return_url is server-derived from this org's portal URL). window.location.href = url; } catch (error: unknown) { proofingInFlightRef.current = false; if (!mountedRef.current) return; if (error instanceof HttpError) { // proof-session.ts's refusal names a real, actionable condition ("your account may // already have been restored"), unlike unlock's uniform one - so it is shown verbatim. setProofingError(describeRefusal(error) || 'We could not start identity verification.'); setState('refused'); return; } // Never reached us - say so, and do not imply the link is spent. setState('unreachable'); } }; return (

Unlock your account

{state === 'checking' && (

)} {(state === 'refused' || state === 'starting') && (
{/* 🚨 CARD 157 β€” SAY WHAT HAPPENED, NOT WHY. The server returned its uniform 403, which is identical for "locked and not yet verified" and "this token does not exist". The only fact we hold is the one in this heading. The previous copy asserted a lock AND a cause ("someone reported that the sign-in email had been changed without permission") on both paths, so a patient who mistyped a link was told a security incident had occurred on their account. CredentialRevertPage's refusal screen is the in-repo model: state the truth, invent nothing, name a route forward that does not depend on us. Pinned by the card-157 tests β€” negatives AND positives, because the negatives alone are satisfiable by deleting this screen, which would strand the very patient the lock exists to protect. */}

Usually that just means the identity check has not been completed yet. It can also mean this is not the current link β€” for example if it was mistyped, or a newer message has since been sent.

{/* CONDITIONAL, deliberately. It keeps the whole affordance for a genuinely locked patient β€” the only exit from the lock β€” without asserting the lock to someone who simply arrived with a bad link. */}

If your account was locked, you can unlock it by verifying your identity β€” this takes a couple of minutes and needs a photo ID and your phone's camera.

{proofingError && (

)}
{/* The phone hand-off: Stripe Identity can move document capture to a phone, so the patient may finish somewhere other than this tab. Starting a FRESH session would overwrite the proofing record back to `pending` and destroy the verifiedAt that is about to land β€” so re-checking has to be its own action, not a second press of the button above. */}
{/* The route that does not depend on us, and the one this screen previously left out entirely. A patient who followed a wrong or stale link has no use for either button above β€” without this they are told a story and handed two dead ends. Mirrors CredentialRevertPage's refusal screen. */}

If you were not expecting this, or neither option works, contact your practice β€” they can verify who you are in person and unlock the account for you.

)} {state === 'verifying' && (
{/* Contract #1: this copy covers the ENTIRE retry window. The server's 403 is deliberately identical for "mid-review" and "never verified", so showing it here would tell a patient who did everything right that they failed. */}

This can take a few minutes β€” please keep this page open. We will unlock your account as soon as the check comes back.

)} {state === 'unlocked' && (

{/* Contract #6. The 200 body names no address on purpose (it would be a live sign-in identifier on an unauthenticated response), but the login email was changed out from under them and then put back β€” so say WHICH one. */}

Sign in with your original email address β€” the one this message was sent to, not the address that was changed. Your password has not changed.

)} {state === 'stalled' && (
{/* Contract #2: NOT the server's refusal. At this point the patient has hit either a genuinely failed verification or an unusually slow one, and neither is served by a sentence that reads as "you did it wrong". */}

We haven't been able to confirm your identity check yet

Sometimes a document takes longer to review. You can try again in a few minutes β€” you will not have to redo the check if it has already gone through.

{/* 🚨 unlock.ts:97-100 names this case explicitly: if a successful 200 was lost in flight, the account is ALREADY unlocked and every retry from here returns the same uniform 403 forever. Signing in is the only thing that resolves that, so it has to be offered. */} {/* Reachable from the LOCKED screen via "check again", so a patient who pressed that before ever verifying lands here having never started. Without this button their only real exit is gone until they reload, and nothing on the page tells them to reload. */} {/* No `disabled={state === 'starting'}` here: `state` is 'stalled' throughout this branch, so that test never fired. beginProofing guards its own re-entry instead. */}

Still stuck? Contact your practice β€” they can verify who you are in person and unlock the account for you.

)} {state === 'unreachable' && (
{/* 🚨 NOT a refusal screen. We never got an answer, so we must not imply the link is spent or the account is locked β€” and the raw `TypeError: Failed to fetch` must never reach the patient. Mirrors AcceptCareTeamInvitePage's own transport-failure state. */}

This is a connection problem, not a problem with your link. Please try again in a moment.

)} {state === 'no-token' && (

This link is not valid. It may have been cut short by your email program.

Open the link from the original “account locked” message again, or contact your practice β€” they can verify who you are in person and unlock the account for you.

)}
); }; export default CredentialUnlockPage;