import { useEffect, useRef, useState } from 'react'; import { assetUrl } from '../../utils/assetUrl'; import { useNavigate, useSearchParams } from 'react-router-dom'; import { Spinner } from '../../components/ui/spinner'; import { Card, CardBody, CardHeader, CardMedia } from '../../components/ui/wp-card'; import { InputControl } from '../../components/ui/input-control'; import { DismissibleAlert } from '../../components/ui/dismissible-alert'; import { Heading } from '../../components/ui/heading'; import { Flex, FlexBlock } from '../../components/ui/flex'; import { Spacer } from '../../components/ui/spacer'; import { useAuthServiceContext, useOrganizationServiceContext } from '../../services'; import { Controller, FormProvider, useForm } from 'react-hook-form'; import { environment } from '../../environments/environment.ts'; import { Organization } from 'fhir/r4'; import { describeAuthError } from '../../utils/authErrorMessages'; import { isPatientExperience } from '../../utils/mountContext'; import { toE164 } from '../../utils/phoneNumber'; import { rememberSignupDetails } from '../../utils/signupDetailsHandoff'; import PracticeDoorCard from '../LoginPage/PracticeDoorCard'; import { clearSetupToken, readSetupToken } from '../../utils/wpAdminApi'; import { rememberSignInEmail, rememberVerifiedEmail } from '../../utils/verifiedEmailHandoff'; type AuthMode = 'signup' | 'verify'; // Deliberately the same wait as LoginPage's - these are twin screens and a patient who meets both // should not have to learn two behaviours. See LoginPage.tsx for why the deadline, not a counter. const RESEND_COOLDOWN_MS = 30_000; const SignupPage = () => { const [authMode, setAuthMode] = useState('signup'); const [isLoading, setIsLoading] = useState(false); const [success, setSuccess] = useState(); const [error, setError] = useState(); const [signupEmail, setSignupEmail] = useState(''); /* * THE OTHER WAY A PRACTICE CAN HOLD ITS OWN SETUP CREDENTIAL, AND FOR ONE KIND OF PRACTICE IT IS THE * ONLY WAY. * * Both existing sources - the tab and the WordPress vault - are written by the setup form at the * moment a practice founds ITSELF through this site. A practice an agency enrolled never ran that * form: its organization was created from the contractors console, so the token was handed to the * agency and this site has never seen it. Every source this screen consults is therefore guaranteed * empty, and the screen used to end there. * * So it can be typed in. It is the same instrument with the same properties - the server still * consumes it, still refuses once the practice has an administrator - and nothing about the token * model is relaxed to accept it from a keyboard rather than from storage. */ const [pastedSetupToken, setPastedSetupToken] = useState(''); const [askForSetupToken, setAskForSetupToken] = useState(false); const { signUp, confirmSignUp, resendConfirmationCode, wpOrganization } = useAuthServiceContext(); // The wait between codes, held as a DEADLINE rather than a self-decrementing counter - a // background tab throttles timers and a counter would drift into "still waiting" long after the // wait was over. Both are set together at send time so no render shows an enabled "(0s)" button. const [resendAvailableAt, setResendAvailableAt] = useState(0); const [secondsUntilResend, setSecondsUntilResend] = useState(0); const { createOrganizationAdmin, fetchOrganizationUserCount } = useOrganizationServiceContext(); const navigate = useNavigate(); const spacerMargin = '24px'; const context = environment.wpData?.context; const methods = useForm({ defaultValues: { email: '', password: '', password2: '', firstname: '', lastname: '', phone: '', dob: '', verificationCode: '' } }); const { control, handleSubmit, reset } = methods; 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 and returns early. if (remaining <= 0) { setResendAvailableAt(0); } }; const id = setInterval(sync, 500); return () => clearInterval(id); }, [resendAvailableAt]); /** Arm the wait. Called wherever a code has demonstrably just been sent. */ const startResendCooldown = () => { setResendAvailableAt(Date.now() + RESEND_COOLDOWN_MS); setSecondsUntilResend(Math.ceil(RESEND_COOLDOWN_MS / 1000)); }; const checkUserCount = async (maxAttempts = 20): Promise => { let attempts = 0; let consecutiveSuccesses = 0; const requiredConsecutiveSuccesses = 3; const checkUsers = () => { return new Promise((resolve) => { const attemptCheck = async () => { attempts++; try { const total: { total: number } = await fetchOrganizationUserCount(wpOrganization.current as Organization); if (total && total.total > 0) { consecutiveSuccesses++; if (consecutiveSuccesses >= requiredConsecutiveSuccesses) { resolve(true); return; } } else { consecutiveSuccesses = 0; } if (attempts >= maxAttempts) { resolve(false); return; } setTimeout(attemptCheck, 3000); } catch (error) { console.error('Error checking user count:', error); consecutiveSuccesses = 0; if (attempts >= maxAttempts) { resolve(false); return; } setTimeout(attemptCheck, 3000); } }; attemptCheck(); }); }; return await checkUsers(); }; const handleSignUp = async (data: any) => { const { email } = data; setIsLoading(true); setError(''); try { if (context === 'admin') { const organization = wpOrganization.current as Organization; /* * SESSIONSTORAGE FIRST, THEN THE WORDPRESS VAULT, AND THE ORDER IS THE WHOLE POINT. * * The ordinary path - create the practice, sign up its admin in the same tab - is served * by sessionStorage with no request at all. The vault is what makes the OTHER paths * possible: closing the tab, coming back tomorrow, finishing from another machine, or a * colleague finishing it. The organization is already tagged to this install by then and * the setup form never returns, so a token that lived only in the tab left the practice * with a practice it could not get into. */ const setupToken = pastedSetupToken.trim() || sessionStorage.getItem('akashere_setup_token') || await readSetupToken(organization?.id as string); if (!setupToken) { /* * NEVER TELL ANYBODY TO START THE ORGANIZATION SETUP OVER. That instruction cannot * be followed: the organization exists and is tagged to this install, so the app * routes past the setup form permanently. Telling somebody to do the one thing that * is impossible is worse than saying nothing - they spend the support call looking * for a button the routing has made unreachable. The old wording is asserted absent * by a guard, so it is paraphrased here rather than quoted. */ /* * AND THE FIELD IS OFFERED AT THE SAME MOMENT, rather than behind a link somebody has to * think to look for. Whoever is reading this is the person holding the code, if anybody * is - they were sent here by their agency and given it. Making them find a second * screen to type it into is the same dead end with an extra step. */ setAskForSetupToken(true); setError( 'To add the first administrator, this practice needs its setup code. If a contractor ' + 'set this practice up for you, they were given the code — paste it below. If the ' + 'practice was created on this website, sign in to WordPress as an ' + 'administrator of this site and open this page again, because that is where the ' + 'code is kept. Either way the practice itself is safe and nothing has been lost.', ); setIsLoading(false); return; } const result: { invitationPending?: boolean } | undefined = await createOrganizationAdmin(data, organization, setupToken); /* * BOTH COPIES GO ONLY WHEN THE CODE WAS ACTUALLY SPENT, and that condition is the change. * * This used to clear in a `finally` - on every outcome, including a throw - on the reasoning * that the server consumes the code on the way in. That is no longer true, and it was the more * expensive half of the same defect: the server now keeps the code whenever it creates nobody, * and the client was deleting its only copy on the way out. The practice would have been left * holding a live credential it could not reach, which is indistinguishable from having none. * * KEEPING A DEAD CODE IS HARMLESS - the next attempt fails with a sentence saying so. Deleting * a live one strands the practice permanently, because it cannot be re-minted. So an outcome * this screen is not certain about keeps it. */ const codeWasSpent = result !== undefined && !result.invitationPending; if (codeWasSpent) { sessionStorage.removeItem('akashere_setup_token'); void clearSetupToken(); } /* * - TWO OUTCOMES, AND ONLY ONE OF THEM CREATED AN ACCOUNT. * * Spencer, 2026-08-21: *"nobody should be able to attach your account to their * organization by typing your address."* When the email already belongs to a real Akashic * account, signup now attaches NOBODY and leaves an invitation instead. * * SO "Admin account created successfully! You can now sign in." WOULD BE A LIE ON THAT * PATH, and an expensive one: they would go to the sign-in screen, authenticate against a * practice they hold no role at, and land on a portal that refuses everything - with * nothing anywhere saying why. Never fall back to the created-account sentence. * * AND IT DOES NOT SEND THEM TO /login, deliberately. The invitation cannot be answered * from THIS practice's portal - holding no role here is precisely what being uninvited * means - so a redirect to sign in would walk them into the refusal this message exists to * prevent. They answer it wherever they already have an account. * * AND IT RETURNS BEFORE `checkUserCount()`, WHICH IS NOT A TIDINESS DECISION. That poll * waits for the newly-created admin to show up in the organization's user count - three * consecutive non-zero reads, three seconds apart, twenty attempts. On this path there is * no admin to wait for and never will be, so it would spin for a FULL MINUTE against a * count that cannot move, and only then print the message. The screen would look hung. */ if (result?.invitationPending) { setSuccess(undefined); setError(undefined); reset(); /* * TWO WAYS OUT, AND THE SECOND ONE IS WHY THE SETUP CODE IS STILL ALIVE. * * Accepting an invitation needs somewhere to accept it FROM, and that surface is a practice * dashboard where the person already holds a role. Somebody whose only Akashic account is a * patient one - or a contractor whose first practice this is - has nowhere to accept from at * all, so telling them only about the invitation is telling them to do something they cannot. * * The other way out is the one the practice was always meant to take: this signup created * nobody, so its setup code was not spent, and whoever is actually going to administer the * practice can use it under their own address. */ setSuccess( 'That email already has an Akashic account, so nothing has been added to it. ' + 'The practice was created and an invitation is now waiting on that account — ' + 'sign in to Akashic the way you normally do and accept it there. If that is ' + 'not where you sign in, the setup code has not been used: whoever will ' + 'administer this practice can sign up here with their own email address.', ); return; } await checkUserCount(); setSuccess(undefined); setError(undefined); reset(); setSuccess('Admin account created successfully! You can now sign in.'); // Sign-in is the next screen and asks for this address: carry it, as the patient path does. rememberSignInEmail(String(email).trim()); setTimeout(() => { navigate('/login'); }, 2_000); } else { await signUp(data); setSuccess(undefined); setError(undefined); setSignupEmail(email); setAuthMode('verify'); startResendCooldown(); } } catch (error: any) { // An address with an unfinished signup: back to the code, not a dead end at sign up. if (context !== 'admin' && error?.name === 'UsernameExistsException') { // An invited patient's account already exists, so signup saved nothing of what they typed: keep the // phone and date of birth for this address, for that account to fill in once it is signed in. rememberSignupDetails(String(email).trim(), { ...(toE164(String(data.phone ?? '')) ? { phone: toE164(String(data.phone)) as string } : {}), ...(data.dob ? { birthDate: String(data.dob) } : {}), }); resumeVerification(String(email).trim()); return; } setError(describeAuthError(error).message); } finally { setIsLoading(false); } }; const handleVerify = async (data: any) => { const { verificationCode } = data; setIsLoading(true); setSuccess(undefined); setError(undefined); try { await confirmSignUp(signupEmail, verificationCode); rememberVerifiedEmail(signupEmail); setError(''); reset(); setSuccess('Account verified successfully! You can now sign in.'); setTimeout(() => { navigate('/login'); }, 2_000); } catch (error: any) { //: same class as LoginPage - the provider's raw string used to reach the patient. // A confirmed address and a wrong code read alike, so this step says nothing about which addresses // have accounts; both point to sign-in, where an invited or already-verified person belongs. const described = describeAuthError(error); const alreadyConfirmed = String(error?.message ?? '').includes('Current status is CONFIRMED'); setError(alreadyConfirmed || described.kind === 'invalid-code' ? 'That code did not work. If this address is already verified, sign in instead.' : described.message); } finally { setIsLoading(false); } }; /** * THE RESEND MUST BE ON SCREEN WHILE A CODE IS OUTSTANDING, and this screen is the one where * its absence hurt most: the Cognito account already exists here, unconfirmed, so "Back to Sign * Up" answers UsernameExistsException. Without this the patient could neither finish signing up * nor start again. */ /* * ARRIVING HERE FROM THE SIGN-IN SCREEN, WITHOUT HAVING JUST SIGNED UP. * * An account abandoned at this step is left unconfirmed in Cognito, and the verify screen was * reachable only by completing a signup - so the one person who needed it could not get to it. Sign * up again answers UsernameExistsException; sign in fails with a sentence that names neither the * cause nor the remedy. * * EVERY OUTCOME PRODUCES THE SAME SENTENCE, AND THAT IS THE POINT. Resending for an address with no * account throws UserNotFound; for one that is already confirmed it throws "User is already * confirmed". Reporting either would turn this screen into an account-existence oracle - which is * precisely the disclosure the sign-in screen refuses to make, so it must not be available one * navigation away. The outcome is discarded deliberately; the copy is conditional on nothing. * * The guard ref, not a dependency list: a resend is a side effect with a rate limit behind it, and * an effect that re-fires on a changing searchParams identity would send several codes and invalidate * each one with the next. */ const [searchParams] = useSearchParams(); const verifyEntryHandled = useRef(false); // STATE as well as the ref, because the screen has to look different: the ref stops the resend firing // twice, and this decides whether the "a code has been sent" sentence may be shown at all. const [arrivedForVerification, setArrivedForVerification] = useState(false); const resumeVerification = (address: string) => { setArrivedForVerification(true); setSignupEmail(address); setAuthMode('verify'); startResendCooldown(); // Absent mail can mean a delay, a throttle or no account, so the copy never infers which. setSuccess( `If you still need to verify your signup for ${address}, enter a verification code here or request another. ` + 'Already verified? Sign in instead.', ); void resendConfirmationCode(address).catch(() => { // Swallowed on purpose - see above. The sentence is already on screen and must not change. }); }; useEffect(() => { if (verifyEntryHandled.current) return; const address = (searchParams.get('verify') || '').trim(); if (!address) return; verifyEntryHandled.current = true; resumeVerification(address); // eslint-disable-next-line react-hooks/exhaustive-deps -- the ref above makes this fire once }, [searchParams]); const handleResendCode = async () => { setIsLoading(true); setError(undefined); setSuccess(undefined); // Clear the superseded code out of the box - resubmitting the one that just stopped working // is the loop this control exists to break. methods.setValue('verificationCode', ''); try { await resendConfirmationCode(signupEmail); setSuccess(`We sent another code to ${signupEmail}.`); startResendCooldown(); } catch (error) { // Left as `unknown` rather than the `any` the neighbouring handlers use - describeAuthError // takes unknown, so the annotation buys nothing and would add a lint error. setError(describeAuthError(error).message); } finally { setIsLoading(false); } }; const onSubmit = (data: any) => { switch (authMode) { case 'signup': return handleSignUp(data); case 'verify': return handleVerify(data); default: return handleSignUp(data); } }; const switchMode = (mode: AuthMode) => { setAuthMode(mode); setSuccess(undefined); setError(undefined); setResendAvailableAt(0); setSecondsUntilResend(0); reset(); }; const renderTitle = () => { switch (authMode) { case 'signup': return context === 'admin' ? 'Create your administrator account' : 'Sign up as Patient'; case 'verify': return 'Verify your account'; default: return 'Sign up to create an account'; } }; const renderForm = () => { switch (authMode) { case 'signup': return ( <> {/* email */} ( <> {fieldState.error && (
Email is required
)} )}/> {/* firstname */} ( <> {fieldState.error && (
First name is required
)} )}/> {/* lastname */} ( <> {fieldState.error && (
Last name is required
)} )}/> {/* Phone + date of birth are captured only for patient self-registration (not the org-admin signup that reuses this form) — they give the dedup matcher enough identity data points to catch a duplicate account at create time. */} {context !== 'admin' && ( <> {/* phone */} toE164(value) !== null || 'Enter a valid phone number' }} render={({ field, fieldState }) => ( <> {fieldState.error && (
{fieldState.error.message || 'Phone number is required'}
)} )}/> {/* date of birth */} !value || value <= new Date().toISOString().slice(0, 10) || 'Date of birth cannot be in the future' }} render={({ field, fieldState }) => ( <> {fieldState.error && (
{fieldState.error.message || 'Date of birth is required'}
)} )}/> )} ); case 'verify': return ( <> {/* * ONLY WHEN A CODE DEMONSTRABLY WENT OUT, which is not the case for somebody who * arrived here from the sign-in screen. * * This sentence states as fact that a code was sent. That is true straight after a * signup, and it is exactly what must NOT be said to a returning visitor: the whole * point of that entry is that we will not confirm whether the address has an * unfinished sign-up behind it. Rendered together, the two lines contradicted each * other on one screen - "if that address has one, we have emailed a code" directly * above "a code has been sent to that address" - which is both a lie for an address * with no account and the very confusion the neutral wording exists to avoid. * * Seen in a browser, not reasoned about: a made-up address produced both sentences. */} {!arrivedForVerification && ( <> A verification code has been sent to {signupEmail}. Please check your email and enter the code below. )} {/* verification code */} ( <> {fieldState.error && (
Verification code is required
)} )}/> ); default: return null; } }; const renderActionButton = () => { let buttonText = 'Sign Up'; switch (authMode) { case 'signup': buttonText = context === 'admin' ? 'Create administrator account' : 'Sign up as Patient'; break; case 'verify': buttonText = 'Verify Account'; break; } return ( // Blue, following Spencer's LoginPage ruling (08-09) - this is that screen's twin. // The ruling is "blue", not a hex: `btn btn-blue` resolves `--primary`, which card // 235 repointed at the logo blue. Layout style preserved; spinner-as-label // preserved with `btn-busy` added alongside. ); }; const renderResendButton = () => { if (authMode !== 'verify') { return null; } const waiting = resendAvailableAt > 0; return ( ); }; const renderSwitchModeButton = () => { if (authMode === 'verify') { return ( <> {/* Offered whatever the address's state: a verified person needs no detour through sign up. */} ); } return ( ); }; // A patient signs up at their practice's door, so it wears the practice's card, as sign-in does. // Setting up a practice has no practice to show yet, so that keeps our mark. const patientDoor = isPatientExperience(); const body = ( <> {patientDoor ? ( {renderTitle()} ) : ( {renderTitle()} )} {authMode === 'signup' && context === 'admin' && (

This is your personal Akashic account. Use your own name and an email address you control. You'll use it to sign in and manage your practice.

)} {success && ( <> {success} )} {error && ( <> {error} )}
{renderForm()} {/* ONLY AFTER THE SCREEN HAS LOOKED, never as a standing field. Asking every practice to produce a credential the app already holds invites somebody to paste the wrong thing, and the ordinary path - found the practice, sign up its admin in the same tab - must stay a form nobody has to fetch anything for. It appears once the sources have been consulted and come back empty. */} {askForSetupToken && authMode === 'signup' && context === 'admin' && ( <> setPastedSetupToken(value ?? '')} label="Setup code" help="The one-time setup code from the contractor who created this practice."/> )} {renderActionButton()} {renderResendButton()}
{isPatientExperience() && ( <> {renderSwitchModeButton()} )} ); if (patientDoor) { return ( {body} ); } return ( {/* Card 364 — this one is always OUR mark (never a practice's upload), so it names us. `alt="Logo"` told a screen-reader user only that an image is an image. */} Akashic Health Records {body} ); }; export default SignupPage;