import { useEffect, useRef, useState } from 'react'; import { Spinner } from "../../components/ui/spinner"; import { useLocation, useNavigate } from 'react-router-dom'; import { signInReturnPath } from '../../components/SignInReturn/signInReturn.tsx'; import { InputControl } from '../../components/ui/input-control'; import { DismissibleAlert } from '../../components/ui/dismissible-alert'; import { Heading } from '../../components/ui/heading'; import { Text } from '../../components/ui/text'; import { Spacer } from '../../components/ui/spacer'; import { useAuthServiceContext } from '../../services'; import { describeAuthError, PASSWORD_NOT_SET } from '../../utils/authErrorMessages'; import { sessionEndSentence, peekSessionEndReason, clearSessionEndReasonWhenSeen } from '../../services/sessionEndReason'; import { Controller, FormProvider, useForm } from 'react-hook-form'; import type { ChallengeNameType } from '@aws-sdk/client-cognito-identity-provider'; import { Patient } from 'fhir/r4'; import { RelatedPatientSelector } from '../../components'; import { isPatientExperience } from '../../utils/mountContext'; import PracticeDoorCard from './PracticeDoorCard'; import { takeSignInHandoff } from '../../utils/verifiedEmailHandoff'; import { environment } from '../../environments/environment'; import './LoginPage.css'; import { AKASHIC_SUPPORT_ADDRESS } from '../../config/support'; type AuthMode = 'signin' | 'signin-otp' | 'patient-selection'; const RESEND_COOLDOWN_MS = 30_000; const CODE_REFUSED = 'We couldn’t sign you in with that code. Start again with your email address. ' + 'If you expected access to this practice, contact the practice administrator.'; // Failures any address can meet. An unknown or already-confirmed address fails differently, and // showing that would say whether it has an account, so those read as a send. const isTransientSendFailure = (error: unknown): boolean => { const { name = '', message = '' } = (error ?? {}) as { name?: string; message?: string }; return /LimitExceeded|TooManyRequests|CodeDeliveryFailure/.test(name) || /failed to fetch|network/i.test(message); }; const LoginPage = () => { const navigate = useNavigate(); const afterSignIn = signInReturnPath(useLocation().state); const { login, admitSignIn, initiateAuth, signInWithPassword, respondToAuthChallenge, resendConfirmationCode, confirmSignUp, setLoggedInAs, wpOrganization } = useAuthServiceContext(); // Every provider call that can answer with tokens is admitted first; only that admission may commit its answer. const admitThisSignIn = () => admitSignIn(isPatientExperience() ? 'Patient' : undefined); /** * - was this arrival caused by the idle timeout? * * READ WITHOUT CONSUMING, AND CLEARED ONLY ONCE SOMEBODY COULD HAVE SEEN IT. * * The initialiser used to read AND clear. That answers "has a component read this?" when the question * is "has a human seen this?" - and the idle timeout signs a tab out after an hour whether or not * anybody is watching. Captured live: the reason was written, the session cleared, and this page * consumed the sentence within the same second, on an unattended tab. The person who came back got a * bare sign-in screen and no way to learn why. * * So the clear moved to the effect below, which waits for the tab to actually be visible. It is still * one-shot: a reason that survived a later deliberate sign-in would say "you were timed out" to * somebody who simply signed in again, which is worse than silence because it is confidently wrong. */ const [sessionEnded] = useState(() => sessionEndSentence(peekSessionEndReason())); /** * The sentence is spent when the screen showing it is on-screen - not when it mounted. A tab signed * out in the background mounts this page immediately, and clearing there is what left the person who * came back with nothing to read. * The branch itself lives in the service so it can be tested; inline here, the only thing a test * could check was that the words were present - and a mutant that cleared on mount kept the words. */ useEffect(() => { if (!sessionEnded) return undefined; return clearSessionEndReasonWhenSeen(); }, [sessionEnded]); const [authMode, setAuthMode] = useState('signin'); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(''); // A failed step leaves the pressed button behind; focus goes to why, so a keyboard user is not left there. const errorRef = useRef(null); useEffect(() => { if (error) errorRef.current?.focus(); }, [error]); const [signinEmail, setSigninEmail] = useState(''); // Kept so a resend re-runs the ORIGINAL sign-in exactly; the context exposes only setLoggedInAs, // never the value, so it cannot be read back at resend time. const [challengeName, setChallengeName] = useState('EMAIL_OTP'); const [session, setSession] = useState(''); const [patientSelectionData, setPatientSelectionData] = useState<{ patients: Patient[]; } | null>(null); // The cooldown is held as a DEADLINE, not a countdown that decrements itself: a background tab // throttles timers, and a self-decrementing counter would drift into "still waiting" long after // the wait was actually over. `secondsUntilResend` is only the label; `resendAvailableAt` is the // fact. Both are set together at send time so no render ever shows an enabled button reading // "(0s)" or a disabled one with nothing to explain it. const [resendAvailableAt, setResendAvailableAt] = useState(0); const [secondsUntilResend, setSecondsUntilResend] = useState(0); /* * WHETHER THE CODE ON SCREEN IS A REPLACEMENT, so the notice can say the previous one is dead. * * SENDING A NEW CODE SILENTLY INVALIDATES THE OLD ONE AND NOTHING SAID SO. It cost three codes in * one evening: somebody read a code out of an earlier email, it was refused twice, and the screen's * own wording sent them back to re-checking the same dead digits. */ const [codeResent, setCodeResent] = useState(false); // A send that failed for a reason any address could meet (network, rate limit, delivery), so the step offers a retry. const [sendFailed, setSendFailed] = useState(false); const [codeRefused, setCodeRefused] = useState(false); // The confirmation code was accepted; the code now on its way is the second, sign-in one. const [addressConfirmed, setAddressConfirmed] = useState(false); const spacerMargin = '24px'; useEffect(() => { if (resendAvailableAt === 0) { return; } const sync = () => { const remaining = Math.ceil((resendAvailableAt - Date.now()) / 1000); setSecondsUntilResend(remaining > 0 ? remaining : 0); // Clearing the deadline is what stops this interval: the effect re-runs on the change // and returns early, so nothing ticks once the wait is over. if (remaining <= 0) { setResendAvailableAt(0); } }; const id = setInterval(sync, 500); return () => clearInterval(id); }, [resendAvailableAt]); const [handoffOnArrival] = useState(takeSignInHandoff); const verifiedEmailOnArrival = handoffOnArrival.email; const methods = useForm({ defaultValues: { // Straight after sign-up the patient should not retype the address they just verified. email: verifiedEmailOnArrival, password: '', otpCode: '' } }); const { control, handleSubmit, reset } = methods; const openCodeStep = (email: string, challengeNameValue?: ChallengeNameType, sessionValue?: string) => { setSigninEmail(email); setChallengeName(challengeNameValue ?? 'EMAIL_OTP'); setSession(sessionValue ?? ''); setAuthMode('signin-otp'); setResendAvailableAt(Date.now() + RESEND_COOLDOWN_MS); setSecondsUntilResend(Math.ceil(RESEND_COOLDOWN_MS / 1000)); }; const handleSignIn = async (data: any) => { const { email, password } = data; setIsLoading(true); setError(''); setSendFailed(false); if (isPatientExperience()) { // LOAD-BEARING BEYOND THIS SCREEN - do not drop it now the selector is gone. // `signOutBeacon.ts:46` returns early unless `_loggedInAs` reads exactly 'Patient' // (staff deliberately leave it unset), so not writing it here would silently stop // the sign-out beacon firing for every patient. setLoggedInAs('Patient'); } else { /* * A STAFF SIGN-IN SAYS THIS DEVICE IS NOT HOLDING A PATIENT SESSION, AND UNTIL NOW IT SAID * NOTHING AT ALL. * * The stamp above is written on SUBMIT, before the code is answered. Abandon that attempt * at the code screen and no session is ever created, so no sign-out ever runs, so nothing * cleared it - and the next staff sign-in on the same device inherited it. For somebody who * holds BOTH records the resolver then chose their patient identity, which does not pair * with the staff mount, and they were shown the cross-portal gate for a portal they had * never reached. Leaving the key untouched is not the same as leaving it unset. * * IT HAS TO HAPPEN HERE, AT SUBMIT, RATHER THAN WHEN THE SESSION BEGINS. `fetchUserData` * closes over `loggedInAs`, so a clear at sign-in SUCCESS would not be visible to the very * resolution it needs to correct - the callback in hand was built with the stale value. The * emailed-code round trip guarantees a re-render between here and there. * * Patient-side behaviour is untouched: this arm cannot run on the patient mount, so the * beacon keeps its stamp. */ setLoggedInAs(undefined); } // A disabled login (e.g. an account that was merged into another during duplicate // resolution) produces no EMAIL_OTP challenge. Pre-auth we can't read the Patient to // prove it was a *merge* specifically, so the copy is merge-aware but not asserting. const mergedOrDisabledMessage = 'This email is no longer active. If you recently linked accounts, sign in with your other email address. Contact support if you need help.'; try { /* * A TYPED PASSWORD GOES THROUGH SRP, AND THAT IS ABOUT THE TOKEN RATHER THAN THE PASSWORD. * * `initiateAuth` with the password inline mints in ONE step, and Cognito does not forward that * call's ClientMetadata to the pre-token trigger - so the session comes out with no DPoP * binding while the screen looks perfect. Counted over fourteen hours in the trigger's own * log: 2,364 unbound mints against 13 bound, and the 13 are the emailed-code sign-ins, which * are two-step. SRP makes a password two-step in the same way. * * NO PASSWORD STILL MEANS THE EMAILED CODE, byte for byte as before - that path is untouched * below and is the only door for an account that has no password to check. */ if (password && !environment.production) { const passwordTicket = await admitThisSignIn(); const tokens = await signInWithPassword(email, password); await login(tokens, passwordTicket); navigate(afterSignIn); return; } const ticket = await admitThisSignIn(); const response = await initiateAuth(email, undefined, password); /* * A PASSWORD THAT WAS ACCEPTED ENDS THE SIGN-IN HERE, WITH NO CODE AND NO SECOND SCREEN. * The pool has no MFA, so the emailed code was never a second factor - it was the * only one. `USER_AUTH` answers a correct password with the tokens directly, and they are * the SAME tokens the code path yields because both carry the same ClientMetadata through * the same flow. * * Checked BEFORE the EMAIL_OTP branch rather than after, because this is the terminal * outcome: falling through it would look for a challenge that is not there and land on the * "no challenge and no throw" arm below, which reports a disabled account. */ if (response.AuthenticationResult) { await login(response.AuthenticationResult, ticket); navigate(afterSignIn); return; } /* * THE PASSWORD IS ANSWERED, NOT SENT UP FRONT, AND THAT IS WHAT MAKES THE TOKEN USABLE. * * Cognito returns a PASSWORD challenge; answering it mints the tokens on the call whose * ClientMetadata the pre-token trigger reads. Sending the password on the initial request * instead makes InitiateAuth mint them in one step, and the token comes back without the * custom claims - the app then cannot resolve who signed in and signs itself out, silently. */ if (response.ChallengeName === 'PASSWORD') { const answered = await respondToAuthChallenge( email, password, response.ChallengeName, response.Session as string, ); if (answered.AuthenticationResult) { await login(answered.AuthenticationResult, ticket); navigate(afterSignIn); return; } // A password challenge that answers with neither tokens nor a throw. Reported rather // than silently dropped - the alternative is the blank bounce this replaced. setError(describeAuthError(new Error('Incorrect username or password.')).message); setLoggedInAs(undefined); return; } /* * COGNITO WAS ASKED FOR A PASSWORD AND OFFERED A MENU INSTEAD, WHICH MEANS THIS ACCOUNT HAS * NO PASSWORD TO CHECK. It answers `SELECT_CHALLENGE` when the preferred challenge is not * available to that user - measured on a real fixture, where the only available challenge * was EMAIL_OTP. * * WITHOUT THIS BRANCH IT FELL TO THE ARM BELOW AND SAID "This email is no longer active", * which tells somebody their account is gone because they typed in an optional field. * * It deliberately does NOT send a code. Changing method on failure is what this card * forbids; the remedy is to clear the field, and the sentence says so. */ if (response.ChallengeName === 'SELECT_CHALLENGE') { setError(PASSWORD_NOT_SET); setLoggedInAs(undefined); return; } if (response.ChallengeName === 'EMAIL_OTP' || response.ChallengeParameters?.challengeType === 'EMAIL_OTP') { openCodeStep(email, response.ChallengeName, response.Session as string); } else { // No OTP challenge and no throw - the login can't proceed (most commonly a // disabled/merged account). Don't blind-navigate to the dashboard (which just // bounces off the auth guard, the "flash"); surface a clear message instead. setError(mergedOrDisabledMessage); setLoggedInAs(undefined); } } catch (error: any) { // describeAuthError maps a disabled/merged account to this same sentence, so the // deliberate merge-aware copy above is preserved rather than replaced. const copy = describeAuthError(error); // A passwordless send that Cognito refuses as unknown still opens the code step, so this // door cannot be used to see whether an address has an account. if (!password && (copy.kind === 'no-account' || copy.kind === 'wrong-practice' || copy.kind === 'not-here')) { // An account that never finished signing up is refused here too. Its confirmation code is sent // and awaited; only a failure any address could meet is shown, as a retry, so nothing here // says whether the address has an account. The code box accepts it - see handleOtpVerification. // A refusal at this practice's door is a confirmed account, so it is sent nothing: its code could never confirm. let unsent = false; if (copy.kind === 'no-account') { try { await resendConfirmationCode(email); } catch (sendError) { unsent = isTransientSendFailure(sendError); } } setAddressConfirmed(false); openCodeStep(email); if (unsent) { setCodeResent(false); setSendFailed(true); setResendAvailableAt(0); setSecondsUntilResend(0); } return; } setError(copy.message); setCodeResent(false); setLoggedInAs(undefined); } finally { setIsLoading(false); } }; const handleOtpVerification = async (data: any) => { const { otpCode } = data; setIsLoading(true); setError(''); // The "a new code is on its way" notice describes the last SEND, and answering the challenge is // a different act. Leaving it up beside a rejection would put two notices on screen again, which // is the whole defect. setCodeResent(false); if (!session) { // No sign-in challenge means the code can only be a confirmation code. Any failure reads as a // mismatch, so the box says nothing about whether the address has an account. try { await confirmSignUp(signinEmail, otpCode); } catch { // Unknown and refused addresses meet the same sentence, and no new code is offered: // another code cannot get past a refusal, so the way on is to start again. setError(CODE_REFUSED); setCodeRefused(true); methods.setValue('otpCode', ''); setIsLoading(false); return; } methods.setValue('otpCode', ''); setCodeResent(false); setAddressConfirmed(true); await handleSignIn({ email: signinEmail }); return; } try { const ticket = await admitThisSignIn(); const response = await respondToAuthChallenge(signinEmail, otpCode, challengeName, session); if (response.AuthenticationResult) { await login(response.AuthenticationResult, ticket); navigate(afterSignIn); } else if (response.ChallengeName === 'CUSTOM_CHALLENGE' && response.ChallengeParameters?.challengeType === 'PATIENT_SELECTION') { const patientsData = JSON.parse(response.ChallengeParameters.patients); const patients = patientsData.entry?.map((entry: any) => entry.resource) || []; setPatientSelectionData({ patients }); setChallengeName(response.ChallengeName); setSession(response.Session as string); setAuthMode('patient-selection'); } } catch (error: any) { //: the provider's own words used to land here verbatim - "Invalid session for // the user, session is expired." - which reads as a mistyped code and never names the // remedy, so the patient retypes a dead code. The remedy itself is now a permanent // control on this screen (renderResendButton), not something the error has to switch on. setError(describeAuthError(error).message); // AND THE REJECTED DIGITS GO. Leaving them is the product asking the person to clean up // after it - eight wrong characters deleted by hand before the right ones can be typed, on // the one screen with a clock running. It also stops a second press resubmitting the very // value that was just refused. methods.setValue('otpCode', ''); } finally { setIsLoading(false); } }; /** Send another code: re-run the email step with the address already known. */ const handleResendCode = async () => { // Clear the outstanding code too - leaving it in the box invites resubmitting the very code // that is about to be superseded, which is the loop is about. methods.setValue('otpCode', ''); setAddressConfirmed(false); // Set BEFORE the request so the notice below cannot be shown for a send that did not happen: // `handleSignIn` clears it again on any failure, and only a successful send leaves it standing. setCodeResent(true); await handleSignIn({ email: signinEmail }); }; const handlePatientSelection = async (selectedPatient: Patient) => { setIsLoading(true); setError(''); try { const ticket = await admitThisSignIn(); const response = await respondToAuthChallenge( signinEmail, selectedPatient.id as string, challengeName, session ); if (response.AuthenticationResult) { await login(response.AuthenticationResult, ticket); navigate(afterSignIn); } } catch (error: any) { setError(describeAuthError(error).message); } finally { setIsLoading(false); } }; const onSubmit = (data: any) => { if (data.email) { data.email = data.email.trim().toLowerCase(); } switch (authMode) { case 'signin': return handleSignIn(data); case 'signin-otp': return handleOtpVerification(data); default: return handleSignIn(data); } }; const switchMode = (mode: AuthMode) => { setAuthMode(mode); setError(''); setCodeRefused(false); setResendAvailableAt(0); setSecondsUntilResend(0); // Leaving it set would greet somebody who went "Wrong address? Start again" with a success // notice about a code sent to the address they just abandoned. setCodeResent(false); reset(); }; const renderTitle = () => { switch (authMode) { case 'signin': return 'Sign in'; case 'signin-otp': return 'Check your email'; case 'patient-selection': return 'Select Patient'; default: return 'Sign in'; } }; const renderForm = () => { switch (authMode) { case 'signin': return ( <> {/* email */} value.trim() !== "", validFormat: (value) => /\S+@\S+\.\S+/.test(value.trim()) || "Enter a valid email address" } }} render={({ field, fieldState }) => ( <> {/* Card 241 (B4) — `type` is what gives this field the portal's 42px height, and it is correct markup in its own right: the 42px rule is written as 21 `input[type=…]` selectors, so an input rendered with NO type attribute matches none of them and falls through to the shadcn primitive's `h-9` (36px). It also buys the email keyboard, browser validation and autofill. This is ONE instance of a class — see the card: ~214 inputs app-wide currently carry no type. */} {/* The field names its own error, so returning to it says why. */} {fieldState.error && (
{fieldState.error.message || "Email is required"}
)} )}/> {/* Production signs people in by emailed code only, so there is no password to ask for. */} {!environment.production && (<> {/* PASSWORD - OPTIONAL, AND THE OPTIONALITY IS THE WHOLE DESIGN. Card 917: this pool has no MFA, so the emailed code is not a second factor on top of a password - it is the only factor, and every account already carries a password nothing ever asked for. Typing one signs you in without a mailbox round trip; leaving it blank is the path every account has always had, and it is the ONLY way in for somebody whose account never had a password set and for anybody who has forgotten theirs. So it must never gain `required`. A field that looks mandatory would lock out exactly the people the code path exists for, and they are the ones least able to work out why. `autoComplete` is what lets a browser or password manager fill it, which is most of the point for somebody signing in every day. */} ( )}/> )} {/* The "Sign In As: Patient / Related Person" selector was REMOVED here (Spencer, 2026-08-11, card 235): *"Related person sign in is retired. You now just log into the portal like anyone else and if you want to access a related person's information you just select them from within the portal."* That is the act-as model in `collaboration-teams/related-person-authority-and- proxy-design.md` — a caregiver signs in as THEMSELVES and the authority to view someone else is a patient-granted Consent tier, not a login-time choice. Do not reintroduce a role question on this screen: it asked the least technical audience in the product to classify themselves before they had done anything, and it was the only WordPress control left on the login form. */} ); case 'signin-otp': return ( <> {/* ONE NOTICE, NEVER TWO - and that is the whole of this screen's defect rather than a tidy-up. The error renders ABOVE this form, so an unconditional notice here put a grey "we've sent a code" underneath a grey rejection, of identical weight, one describing a past success and one a present failure. A person holding a code that dies in minutes read "we have sent a code", waited for mail that was never coming, and never pressed the control that would send one. Reordering the two boxes does not fix it. Two notices IS the defect. AND THE RESEND CASE HAS TO SAY THE OLD CODE IS DEAD, because sending a new one silently invalidates the previous one and nothing said so. Measured cost: three codes in one evening, refused twice, because the newest email was not obviously the only one that works. */} {/* THE CODE HAS A STATED LIFE, because a person on this screen is holding something that expires and had no way to know how long they had. Without it the only way to discover the code was dead was to type it and be refused - at which point they cannot tell a mistyped code from an expired one, and the two need opposite responses. FIFTEEN MINUTES IS MEASURED, NOT COPIED FROM THE DECK. `AuthSessionValidity` is 15 on all three user pool clients - patient, practitioner and related person - which is Cognito's maximum and governs how long a native EMAIL_OTP stays valid. This screen is shared by all three, so one sentence is true for everybody who reaches it. If that setting ever moves, this line becomes a promise the server does not keep, and it is a server-side value this client cannot read at runtime. */} {/* Conditional on every path: a refused address reaches this step too, so the wording must neither claim a send nor differ from a real one. */} {!error && sendFailed && ( We could not send a code just now. Choose Send a new code to try again. )} {/* A code on its way is routine, so it reads as plain text, never as an alert. */} {!error && !sendFailed && (

{addressConfirmed ? `Your email address is confirmed. Check ${signinEmail} for a code to sign in.` : codeResent ? `If ${signinEmail} can sign in here, we sent a new code to it. Use the latest one.` : `If ${signinEmail} can sign in here, we sent a code to it. A code expires 15 minutes after it is sent.`}

)} {/* A refused staff address gets no mail, so the step names what to do; it must not say why. */} {!error && !sendFailed && !addressConfirmed && !isPatientExperience() && (

No code? Check the address and your spam folder, or ask your practice or contractor administrator to confirm your access.

)} {!error && } {/* OTP code */} ( <> {/* `type="text"`, deliberately NOT `type="number"`: a one-time code is a STRING of digits, and number inputs strip leading zeros, accept `e`/`+`/`-`, and expose a spinner nobody wants on a passcode. `inputMode` is what actually raises the numeric keypad. */} {fieldState.error && (
Code is required
)} )}/> ); case 'patient-selection': return ( <> Please select the patient you'd like to view or manage for this session. {patientSelectionData && ( handlePatientSelection(data.patient as Patient)} isLoading={isLoading} /> )} ); default: return null; } }; const renderActionButton = () => { if (authMode === 'patient-selection') { return null; } const buttonText = 'Sign in'; // BLUE, not the green this sweep uses for submit (Spencer, 08-09). Role-colour is a // rule for inside the app; this is the page's only action, so "green means submit" // carries no information here. // The ruling is "blue", NOT a particular hex. `btn btn-blue` resolves `--primary`, // which moved off WordPress's admin blue onto the logo's own #2f6fa5 - so // this screen did change colour, by design, and the ruling still holds. // The inline style is LAYOUT (full-width, centred) and is preserved. The // spinner-as-label busy state is preserved verbatim; `btn-busy` is ADDED alongside // it, not swapped for it. // (A JSX comment cannot be the first thing inside `return (` either - that slot // takes an expression, same trap as `{cond && (`.) return ( ); }; /** * THE RESEND MUST BE ON SCREEN WHILE A CODE IS OUTSTANDING - the same rule the rest of the * product already follows (PatientSettingsPage on both channels, StaffSmsVerificationDialog, * DuplicateMatchPrompt). put this control inside the error block, gated on * `describeAuthError().canResendCode`, so it appeared for exactly one failure: an expired * challenge. The commonest failure on a passwordless login produces NO error at all - the * email does not arrive, or lands in spam - and that patient was left with "Complete Sign In" * and "Back to Sign In", with the whole resend path unreachable. */ const renderResendButton = () => { if (authMode !== 'signin-otp') { return null; } const waiting = resendAvailableAt > 0; return ( ); }; /** * THE WAY OUT FOR SOMEBODY WHO NEVER FINISHED SIGNING UP - AND IT MUST NOT SAY THAT ABOUT THEM. * * An account abandoned at the verification step is left unconfirmed. Every later sign-in attempt on * it fails, and the sentence is the generic shrug: no cause, no remedy, and no route back to an * account the person genuinely owns. * * THE OBVIOUS FIX IS FORBIDDEN, and that is the whole design of this control. "This account exists * but is not verified" is an ACCOUNT-EXISTENCE DISCLOSURE: the sign-in form would become a free tool * for discovering who has an account at a healthcare product, which is a worse defect than the one * being fixed. And it cannot be keyed on the failure either - the unconfirmed refusal is raised * before any code is entered, so anyone could type any address and read the answer off the screen. * * SO THE ROUTE IS OFFERED, AND THE DIAGNOSIS IS NOT. It appears on EVERY sign-in failure, identically, * whatever the cause - which is exactly why it reveals nothing. The person who knows they abandoned a * signup recognises it; nobody else learns anything from it. The verify screen it leads to says the * same sentence whether or not the address has an unfinished signup behind it. */ const renderUnfinishedSignupRoute = () => { if (!isPatientExperience() || (authMode !== 'signin' && authMode !== 'signin-otp')) { return null; } return ( ); }; const renderSwitchModeButton = () => { if (authMode === 'signin-otp') { // A resend or code check still in flight would reopen this step for the abandoned address. return ( ); } if (authMode === 'patient-selection') { return ( ); } return ( ); }; // One full screen, so the card centres in the viewport and the page ground reaches its foot. return ( {/* Card 240 / finding B2 — the document order was H1 "Patient Portal" (the WordPress page title) H3 here H2 in the theme footer, so our own heading skipped a level and then the footer appeared to outrank it. Only THIS heading is ours to change; the H1 and the footer H2 belong to the theme. As the one section under the page's H1, ours is the H2. Nothing else in this file is touched — claude-4 has been working in it. */} {renderTitle()} {/* CARD 288, CONDITION 3 — NEVER SILENT. Landing on a login screen with no explanation reads as a bug and gets reported as one, correctly: the user did not sign out, and nothing told them anything happened. This is the other half of the warning — the warning explains what is ABOUT to happen, this explains what DID. Read-and-clear: it describes one event, so it must not greet them again on the next visit to this page. */} {/* THE SENTENCE COMES FROM THE MODULE, not from here. Four causes end a session without the person asking - idle, a refused refresh, a device key this browser can no longer produce, and an account the app cannot open a session with - and only the first of them ever said anything. Writing the copy at the render site is how the other three ended up with none. A DELIBERATE sign-out records nothing and this renders nothing, which is the control on the whole mechanism. */} {sessionEnded && ( {sessionEnded} )} {handoffOnArrival.verified && verifiedEmailOnArrival && authMode === 'signin' && !error && ( Your account is verified. Sign in to continue. )} {error && ( <>
{error}
{authMode === 'signin' && renderUnfinishedSignupRoute()} )} {authMode === 'patient-selection' ? ( renderForm() ) : ( {/* The deck puts the label 32px under the heading's top (a 28px line box and 4px); the shared section title draws a 20px line box with no margin, so the form keeps a 12px gap (P01 return). */}
{!(authMode === 'signin-otp' && codeRefused) && (<> {renderForm()} {renderActionButton()} {renderResendButton()} )}
{/* An unconfirmed account is sent no login code; the way back to its verification is here. */} {authMode === 'signin-otp' && renderUnfinishedSignupRoute()}
)} {/* Staff get only the way back from a code sent to a mistyped address; sign-up and phone recovery are patient routes. */} {!isPatientExperience() && authMode === 'signin-otp' && renderSwitchModeButton()} {/* Somebody refused here may have nobody at their practice left to ask, so Akashic support is reachable from the sign-in page itself, signed out. The same words for everyone: they say nothing about any account. */} {!isPatientExperience() && (

Can’t get into your practice? Email Akashic support at{' '} {AKASHIC_SUPPORT_ADDRESS}.

)} {isPatientExperience() && ( <> {renderSwitchModeButton()} )}
); }; export default LoginPage;