import { useCallback, useEffect, useMemo, useRef, useState, type FormEvent } from "react"; import { Link, Navigate, useNavigate, useSearchParams } from "react-router-dom"; import { Heading } from "../ui/heading"; import { InputControl } from "../ui/input-control"; import { DismissibleAlert } from "../ui/dismissible-alert"; import { useAuthServiceContext } from "../../services/AuthService"; import { environment } from "../../environments/environment"; import { requestNeutralAccount } from "../../services/neutralAccountRequest"; import { askAboutInvitedAccount, endInvitationSession, invitationCallsFor, sendSignInCode, signInWithCode, type CodeChallenge, type InvitationSession, } from "../../services/clinicianInvitationEntry"; import { describeAuthError } from "../../utils/authErrorMessages"; import ClinicianInvitationNotice from "./ClinicianInvitationNotice"; import PracticeDoorCard from "../../pages/LoginPage/PracticeDoorCard"; import { rememberInvitationToken } from "./invitationToken"; import { rememberVerifiedEmail } from "../../utils/verifiedEmailHandoff"; import { signInWithOwnerSecret } from "../../services/ownerAccept"; import { forgetJustJoined, rememberJustJoined } from "../../utils/justJoinedHandoff"; export const INVALID_LINK_MESSAGE = "This invitation link is invalid or already used. If you joined, sign in below. Otherwise copy the full link or ask the practice for a new one."; export const EXPIRED_LINK_MESSAGE = "This invitation has expired. Ask the practice to send a new one."; /** * Where the emailed link lands. Somebody already signed in here is asked on the dashboard. Anybody else * answers as themselves: they sign in with their own Akashic account, or create one, and that session * reaches only the invitation. The staff sign-in opens to them once they have joined. */ export const ClinicianInvitationLanding = () => { const [params] = useSearchParams(); const { isLoggedIn } = useAuthServiceContext(); const token = params.get("token"); // The link names the address it was sent to, and marks it new when it had no account then. const invitedEmail = params.get("email")?.trim() || undefined; const isNew = params.get("new") === "1"; // The address had an account when the invitation was sent: the page never offers to create one. const isKnown = !isNew && params.get("known") === "1"; // A new invitee's link also names them, so the account starts with the name the practice used. const invitedName = isNew ? { first: params.get("first")?.trim() || "", last: params.get("last")?.trim() || "" } : undefined; // Keyed by the link: a different link on the mounted route starts over, with no session and no preview. if (token && !isLoggedIn) return ; if (token) rememberInvitationToken(token); return ; }; type Step = | { kind: "opening" } | { kind: "email" } | { kind: "create" } | { kind: "code"; challenge: CodeChallenge } | { kind: "answer"; session: InvitationSession } | { kind: "closed"; message: string }; export const ClinicianInvitationEntry = ({ token, invitedEmail, isNew = false, isKnown = false, invitedName }: { token: string; invitedEmail?: string; isNew?: boolean; isKnown?: boolean; invitedName?: { first: string; last: string }; }) => { const { wpOrganization, login, admitSignIn, setLoggedInAs } = useAuthServiceContext(); const practice = wpOrganization.current; const navigate = useNavigate(); // Sign-up is offered for any address except the invited one when it is known to have an account. const hasAccount = (address: string) => { const normalised = address.trim().toLowerCase(); if (createdEmail && normalised === createdEmail) return true; return Boolean((accountNow === "known" || (accountNow === "unknown" && isKnown)) && invitedEmail && normalised === invitedEmail.toLowerCase()); }; // A new invitee starts on sign-up; anybody else named by the link is sent a code at once (below). The address // stays editable throughout, since an invitation may be answered from another account. // A link naming the address asks first whether it has an account now; the link's own marks are only a fallback. // Every link opens on "Opening" until that check answers: nothing is offered for a link not yet known to be open. const [step, setStep] = useState({ kind: token ? "opening" : "email" }); const [accountNow, setAccountNow] = useState<"known" | "new" | "unknown">("unknown"); // The address an account was just created for here: it has one now, so it is never asked again. const [createdEmail, setCreatedEmail] = useState(null); // The address this session signed in with, handed to the staff sign-in once joined. const [signedInEmail, setSignedInEmail] = useState(""); const [email, setEmail] = useState(invitedEmail ?? ""); const [firstname, setFirstname] = useState(invitedName?.first ?? ""); const [lastname, setLastname] = useState(invitedName?.last ?? ""); const [code, setCode] = useState(""); const [busy, setBusy] = useState(false); const [noticeStage, setNoticeStage] = useState<"ask" | "code" | "done" | "closed" | null>(null); const onNoticeStage = useCallback((kind: "ask" | "code" | "done" | "closed") => setNoticeStage(kind), []); const [joined, setJoined] = useState(false); const [entryFailed, setEntryFailed] = useState(false); const [error, setError] = useState(null); const session = step.kind === "answer" ? step.session : null; const calls = useMemo( () => (session ? invitationCallsFor(session, practice?.id ?? "") : null), [session, practice?.id], ); useEffect(() => () => { if (session) void endInvitationSession(session); }, [session]); const run = async (work: () => Promise) => { setBusy(true); setError(null); try { await work(); } catch (failure) { setError(describeAuthError(failure).message); } finally { setBusy(false); } }; // Set once the link is refused; a code answer still in flight then changes nothing. const closed = useRef(false); const askForCode = async (address: string) => { const challenge = await sendSignInCode(address); if (closed.current) return; setCode(""); setStep({ kind: "code", challenge }); }; // Once per link: ask about the account as it is now, then open on sign-up or send the code to the invited address. const openedForLink = useRef(false); useEffect(() => { if (!token || openedForLink.current) return; openedForLink.current = true; void (async () => { const account = await askAboutInvitedAccount(token, practice?.id ?? ""); if (account === "invalid" || account === "expired") { closed.current = true; setStep({ kind: "closed", message: account === "expired" ? EXPIRED_LINK_MESSAGE : INVALID_LINK_MESSAGE }); return; } if (!invitedEmail) { setStep({ kind: "email" }); return; } setAccountNow(account); if (account === "new" || (account === "unknown" && isNew)) { setStep({ kind: "create" }); return; } await run(() => askForCode(invitedEmail)); })(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [invitedEmail, isNew, token]); // One code reaches the workspace. This session names no practice and the staff portal signs in on // the staff client, so the accept hands back a one-time secret that answers the staff client's challenge, as // the owner Accept page does. Without one, or if that sign-in fails, a staff sign-in link is offered with the // address already in it; the membership stands either way. const enterWorkspace = async (joined: { organizationId: string; practitionerId?: string; signIn?: { secret: string } | null }) => { const toStaffSignIn = () => { rememberVerifiedEmail(signedInEmail); setEntryFailed(true); }; const secret = joined.signIn?.secret; if (!secret || !signedInEmail) return toStaffSignIn(); try { setLoggedInAs(undefined); // Admitted before the secret is answered, at the staff client it mints on; only this admission may commit it. const ticket = await admitSignIn(undefined, environment.cognitoPractitionerClient); const tokens = await signInWithOwnerSecret(signedInEmail, secret, joined.organizationId); if (!tokens) throw new Error("no staff sign-in"); // The session's first read of this member's record can trail the join by the role index's lag; that one // read is waited out (utils/justJoinedHandoff). if (joined.practitionerId) rememberJustJoined(joined.organizationId, joined.practitionerId); await login(tokens, ticket); navigate("/dashboard/home", { replace: true }); } catch { // A session that never opened never spent the mark; it must not stretch a later refusal. forgetJustJoined(); toStaffSignIn(); } }; const goTo = (next: Step) => { setError(null); setStep(next); }; const onEmail = (event: FormEvent) => { event.preventDefault(); void run(() => askForCode(email)); }; // The neutral signup, chosen only by somebody with no account. It answers alike for a known address and // changes nothing for it, so an existing account still arrives at its own sign-in. const onCreate = (event: FormEvent) => { event.preventDefault(); void run(async () => { const outcome = await requestNeutralAccount({ email, firstname, lastname, for: "invitation" }); if (outcome.outcome === "refused") return setError(outcome.message); if (outcome.outcome === "unreachable") { return setError("We could not reach Akashic. Check your connection and try again."); } setCreatedEmail(email.trim().toLowerCase()); await askForCode(email); }); }; const onCode = (event: FormEvent) => { event.preventDefault(); if (step.kind !== "code") return; const { challenge } = step; void run(async () => { const signedIn = await signInWithCode(challenge, code); setSignedInEmail(challenge.username); setStep({ kind: "answer", session: signedIn }); }); }; // A lapsed or refused code is recovered by a fresh challenge for the same address. const onResend = () => { if (step.kind !== "code") return; const { username } = step.challenge; void run(() => askForCode(username)); }; return ( // The practice's own door, as sign-in and sign-up wear it; the card names the practice above.
{practice?.name ? `Join ${practice.name}` : "Join this practice"} {error && {error}} {step.kind === "opening" &&

Opening your invitation…

} {step.kind === "closed" && ( <>

{step.message}

{/* An expired invitation was never answered, so there is nothing to sign in to yet. */} {step.message === INVALID_LINK_MESSAGE && Go to the staff sign-in} )} {step.kind === "email" && (

Sign in with your Akashic account to answer. It need not use the address the invitation went to.

setEmail(value ?? "")} />
{!hasAccount(email) && ( )}
)} {step.kind === "create" && (

Only for somebody with no Akashic account yet. If you have one, sign in with it instead: that account is the one that joins.

setFirstname(value ?? "")} /> setLastname(value ?? "")} /> setEmail(value ?? "")} />
)} {step.kind === "code" && (
{hasAccount(step.challenge.username) ? (

We emailed {step.challenge.username} a sign-in code.

) : (

If {step.challenge.username} has an Akashic account, we emailed it a sign-in code. With no account yet, no code will come: choose “I am new to Akashic” instead.

)} setCode(value ?? "")} />
{!hasAccount(step.challenge.username) && ( )}
)} {step.kind === "answer" && calls && ( <> { setJoined(true); void enterWorkspace(outcome); }, onStage: onNoticeStage, signInWhenUsed: true, }} /> {/* No sign-in beside an unanswered invitation: Accept enters the workspace itself. */} {noticeStage === null &&

Opening your invitation…

} {noticeStage === "closed" && (

You declined this invitation. If you change your mind, ask {practice?.name ?? "the practice"} to send you a new one.

)} {joined && entryFailed && ( <>

We could not open the practice for you. Sign in to open it.

Go to the staff sign-in )} )}
); }; export default ClinicianInvitationLanding;