import { useCallback, useEffect, useRef, useState } from 'react'; import { AlertTriangle, Loader2, Lock, Mail } 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 { REVERT_TOKEN_PARAM, readLinkToken } from '../../utils/credentialRecoveryLink'; import { withoutTransportWrapper } from '../../utils/fetchFailureMessage'; // D4, Task 12 - the "wasn't me" landing page (`/credential-revert?t=`). // // MUST be reachable WITHOUT a session. The patient clicking this cannot sign in - that is the // entire reason the link exists - and by the time this page finishes, revert.ts has DISABLED their // Cognito user outright. It is mounted in App.tsx alongside /login and /accept-invitation, outside // RequireAuth. Moving it inside the authed shell redirects to /login, and that failure is silent: // the redirect looks like a working app to everyone except the locked-out patient it exists for. // // The success copy says LOCKED, not merely "undone": a patient who believes they are finished will // not go looking for the unlock email, and their account stays locked with no path back. // // THIS PAGE CANNOT START IDENTITY VERIFICATION, AND MUST NOT PRETEND TO. Starting proofing needs // an UNLOCK token (POST /R4/credential-recovery/proof-session takes `{ token }` and derives the // patient from it). revert.ts's 200 body is `{ status: 'reverted-and-locked', tagSynced }` - no // unlock token - and the unlock token is only ever delivered in the account-locked EMAIL, to the // address this revert just restored. So the one job of this screen after a successful revert is to // hand the patient off to that email. An earlier draft of the plan had a "Verify my identity" button // here; it would have had nothing to call. See task-12-report.md. // // ACTS ON LOAD, EXACTLY ONCE. redeemRevertToken deletes the token before doing anything else, so // a replayed post always refuses - a double-invoked effect would show an error screen to a patient // whose account was in fact reverted and locked. type State = 'no-token' | 'working' | 'reverted' | 'refused' | 'unreachable'; /** * HttpService wraps every non-OperationOutcome refusal as `Request failed: `. * The prefix is noise for a patient; the message itself is the server's own and is shown verbatim. * * NEVER SUBSTITUTE OUR OWN TEXT HERE. Every dead-token outcome - unknown, expired, already used, * a DynamoDB fault - returns the same deliberately uniform message so an attacker cannot probe which * links are live. Replacing it with "please try again" would be actively wrong: redeemRevertToken * burns the token on every path, so a retry can never succeed. * * ONLY EVER CALLED ON AN HttpError - i.e. only when the server actually answered. A dropped * connection throws a bare `TypeError: Failed to fetch`, which carries no `Request failed:` prefix * and would otherwise be rendered as "Failed to fetch" under a red alert, presenting a VALID, * UNREDEEMED token as a dead link with no way to retry. See the 'unreachable' state. */ const describeRefusal = (error: HttpError): string => withoutTransportWrapper(error.message) || 'This link is no longer valid.'; export const CredentialRevertPage = () => { const { revertCredential } = useCredentialRecoveryService(); const [state, setState] = useState('working'); const [refusal, setRefusal] = useState(''); const startedRef = useRef(false); const [token, setToken] = useState(); const submit = useCallback((activeToken: string) => { setState('working'); revertCredential(activeToken) .then(() => setState('reverted')) .catch((error: unknown) => { // A DROPPED CONNECTION IS NOT A REFUSAL. Only an HttpError means the server // answered; a transport failure means the request very likely never landed, so the // token is still unredeemed and a retry is the right offer. Presenting it as a dead // link - or worse, rendering "Failed to fetch" - strands a patient holding a // perfectly good token. AcceptCareTeamInvitePage draws exactly this distinction. if (error instanceof HttpError) { setRefusal(describeRefusal(error)); setState('refused'); return; } setState('unreachable'); }); }, [revertCredential]); useEffect(() => { if (startedRef.current) return; // Read straight off the raw href rather than useLocation(): this is a HashRouter SPA, so the // real link is `.../portal/#/credential-revert?t=...`, and the page must also work if the link // arrives with the token in the document query instead. See utils/credentialRecoveryLink.ts. const found = readLinkToken(REVERT_TOKEN_PARAM, window.location.href); if (!found) { setState('no-token'); return; } startedRef.current = true; setToken(found); submit(found); // Runs once on mount; the single-use token makes a re-run actively harmful. // eslint-disable-next-line react-hooks/exhaustive-deps }, []); return (

Undo a sign-in email change

{state === 'working' && (

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

Nobody can use it while we sort this out. To unlock it, verify your identity — this takes a couple of minutes and needs a photo ID and your phone's camera.

{/* The hand-off. This page holds no unlock token, so the email is the only way on — say so plainly, and name where it went. */}

Your password has not changed. If you cannot find the email, contact your practice and they can help you unlock the account in person.

)} {state === 'refused' && (
{/* The server's own words, verbatim — see describeRefusal. No "try again": the token is burned on every failure path, so a retry cannot ever work. */}

If you did not make this change, contact your practice — they can verify who you are in person and restore your account.

)} {state === 'unreachable' && (
{/* NOT a refusal screen — we never got an answer, so the token is almost certainly still good and the patient must be given the retry. */}

This is a connection problem, not a problem with your link — it has not been used yet. Please try again in a moment.

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

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

Try opening the link from the original message again, or contact your practice for help.

)}
); }; export default CredentialRevertPage;