marmar-context / web / context / src / clinician / session.tsx
session.tsx
Raw
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>
);