import type { ReactNode } from "react";
import { createContext, useCallback, useContext, useEffect, useRef, useState } from "react";
import { AUTH_EVENT, clinicianApi } from "../api/client";
import type { ClinicianProfile, ClinicianSession, ClinicProfile } from "../api/types";
import { sessionEnded, shouldLock } from "../logic/access";
import { AuthFrame } from "./auth/AuthFrame";
import { StepUp } from "./auth/StepUp";
import { Terms } from "./auth/Terms";
import { Callout, cn, Icon, linkButtonClass } from "./kit";
import { navigate } from "./router";
import { Spinner } from "./ui";
type Clinician = {
session: ClinicianSession;
me: ClinicianProfile;
clinic: ClinicProfile;
setMe: (me: ClinicianProfile) => void;
setClinic: (clinic: ClinicProfile) => void;
lock: () => void;
signOut: () => Promise<void>;
};
const ClinicianContext = createContext<Clinician | null>(null);
export function useClinician(): Clinician {
const value = useContext(ClinicianContext);
if (!value) throw new Error("useClinician needs the signed-in app.");
return value;
}
/** "Dr Emeka Okafor". */
export const displayName = (me: ClinicianProfile) => `${me.title} ${me.firstName} ${me.lastName}`.trim().replace(/\s+/g, " ");
/** "Dr Okafor". */
export const shortName = (me: ClinicianProfile) => `${me.title} ${me.lastName}`.trim();
const ACTIVITY = ["pointerdown", "keydown", "wheel", "touchstart", "focus"] as const;
/**
* The gate for /app: a session with the second step, then the profile and clinic.
* Locks after the chosen idle time and unmounts everything behind it, so no patient
* data stays on screen (ACC-7).
*/
export function ClinicianGate({ path, children }: { path: string; children: ReactNode }) {
const [session, setSession] = useState<ClinicianSession | null>(null);
const [locked, setLocked] = useState(false);
const [terms, setTerms] = useState(false);
const [me, setMe] = useState<ClinicianProfile | null>(null);
const [clinic, setClinic] = useState<ClinicProfile | null>(null);
const lastActivity = useRef(Date.now());
const toSignIn = useCallback((reason?: string) => {
navigate(`/sign-in?next=${encodeURIComponent(location.pathname)}${reason ? `&reason=${reason}` : ""}`, { replace: true });
}, []);
useEffect(() => {
clinicianApi.session().then(
(s) => {
if (s.verification !== "verified") return toSignIn();
setSession(s);
setLocked(s.stepUp !== "done");
setTerms(!s.termsAccepted);
},
() => toSignIn(),
);
}, [toSignIn]);
useEffect(() => {
if (!session || locked || terms) return;
Promise.all([clinicianApi.me(), clinicianApi.clinic()]).then(([m, c]) => {
setMe(m);
setClinic(c);
}, () => undefined);
}, [session, locked, terms]);
useEffect(() => {
const onAuth = (e: Event) => {
const code = (e as CustomEvent<string>).detail;
// 403 STEP_UP_REQUIRED and SESSION_LOCKED: the session is fine but needs the code again.
if (code === "STEP_UP_REQUIRED" || code === "SESSION_LOCKED") setLocked(true);
else if (code === "TERMS_REQUIRED") setTerms(true);
else toSignIn(code === "SESSION_EXPIRED" ? "expired" : undefined);
};
window.addEventListener(AUTH_EVENT, onAuth);
return () => window.removeEventListener(AUTH_EVENT, onAuth);
}, [toSignIn]);
const lock = useCallback(() => {
setLocked(true);
void clinicianApi.lock().catch(() => undefined);
}, []);
useEffect(() => {
if (!session || locked) return;
const touch = () => (lastActivity.current = Date.now());
touch();
ACTIVITY.forEach((type) => window.addEventListener(type, touch, { passive: true, capture: true }));
const minutes = me?.idleLockMinutes ?? session.idleLockMinutes;
const timer = setInterval(() => {
if (sessionEnded(session.expiresAt, Date.now())) toSignIn("expired");
else if (shouldLock(lastActivity.current, Date.now(), minutes)) lock();
}, 5_000);
return () => {
clearInterval(timer);
ACTIVITY.forEach((type) => window.removeEventListener(type, touch, { capture: true }));
};
}, [session, locked, me, lock, toSignIn]);
const signOut = useCallback(async () => {
await clinicianApi.signOut().catch(() => undefined);
navigate("/sign-in", { replace: true });
}, []);
const signOutLink = (
<button type="button" className={cn(linkButtonClass, "self-center text-[13px] text-muted no-underline hover:text-ink")} onClick={() => void signOut()}>
<Icon name="logout-box-r-line" size={14} /> Sign out
</button>
);
if (!session) return <Opening />;
if (locked)
return (
<AuthFrame below={signOutLink}>
{session.stepUp !== "enrol" && (
<Callout role="status" icon="lock-line">
Context is locked{path.startsWith("/app") ? " and patient data is hidden" : ""}. It locks after {me?.idleLockMinutes ?? session.idleLockMinutes} minutes without
activity.
</Callout>
)}
<StepUp
mode={session.stepUp === "enrol" ? "enrol" : "verify"}
onDone={(s) => {
setSession(s);
setLocked(false);
setTerms(!s.termsAccepted);
}}
/>
</AuthFrame>
);
if (terms)
return (
<AuthFrame below={signOutLink}>
<Terms
onDone={(s) => {
setSession(s);
setTerms(false);
}}
/>
</AuthFrame>
);
if (!me || !clinic) return <Opening />;
return (
<ClinicianContext.Provider value={{ session, me, clinic, setMe, setClinic, lock, signOut }}>{children}</ClinicianContext.Provider>
);
}
/** While the session and profile load: the frame and a quiet spinner, never a flash of patient data. */
const Opening = () => (
<div className="flex min-h-dvh items-center justify-center bg-frame">
<Spinner label="Opening Context" />
</div>
);