marmar-context / web / context / src / clinician / auth / SignIn.tsx
SignIn.tsx
Raw
import { useEffect, useState } from "react";
import type { FormEvent } from "react";
import { ApiError, clinicianApi } from "../../api/client";
import type { ClinicianSession } from "../../api/types";
import { Button, Callout, cn, Field, Icon, Input, linkButtonClass } from "../kit";
import { Link, navigate, useLocation } from "../router";
import { errorText, Spinner } from "../ui";
import { AuthFrame, codeClass, StepLinks, StepTitle } from "./AuthFrame";
import { StepUp } from "./StepUp";

const safeNext = (next: string | null) => (next && next.startsWith("/app") && !next.startsWith("//") ? next : "/app/today");

/** ACC-7: work email, then a one-time code, then the second step. */
export default function SignIn() {
  const { query } = useLocation();
  const next = safeNext(query.get("next"));
  const reason = query.get("reason");
  const [stage, setStage] = useState<"checking" | "email" | "code" | "enrol" | "verify" | "pending">("checking");
  const [email, setEmail] = useState("");
  const [code, setCode] = useState("");
  const [error, setError] = useState("");
  const [busy, setBusy] = useState(false);
  const [verification, setVerification] = useState<ClinicianSession["verification"]>("verified");

  const afterSession = (s: ClinicianSession) => {
    setVerification(s.verification);
    if (s.verification !== "verified") return setStage("pending");
    if (s.stepUp === "done") return navigate(next, { replace: true });
    setStage(s.stepUp === "enrol" ? "enrol" : "verify");
  };
  useEffect(() => {
    clinicianApi.session().then(afterSession, () => setStage("email"));
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  const sendCode = async (e?: FormEvent) => {
    e?.preventDefault();
    if (!/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(email.trim())) {
      setError("Enter your work email.");
      return;
    }
    setBusy(true);
    setError("");
    try {
      await clinicianApi.sendCode(email.trim().toLowerCase());
      setStage("code");
    } catch (err) {
      setError(errorText(err));
    } finally {
      setBusy(false);
    }
  };
  const verify = async (e: FormEvent) => {
    e.preventDefault();
    if (!/^\d{6}$/.test(code.trim())) {
      setError("Enter the 6-digit code from the email.");
      return;
    }
    setBusy(true);
    setError("");
    try {
      await clinicianApi.verifyCode(email.trim().toLowerCase(), code.trim());
      afterSession(await clinicianApi.session());
    } catch (err) {
      setError(err instanceof ApiError && err.status === 401 ? "That code did not sign you in. Request a new one." : errorText(err));
    } finally {
      setBusy(false);
    }
  };

  return (
    <AuthFrame>
      {reason === "expired" && stage === "email" && (
        <Callout tone="amber" role="status" icon="time-line">
          Your session ended after 12 hours. Sign in again.
        </Callout>
      )}
      {reason === "signed-out" && stage === "email" && (
        <Callout role="status" icon="logout-box-r-line">
          You are signed out on every device.
        </Callout>
      )}
      {stage === "checking" && <Spinner label="Checking your session" className="self-center py-6" />}
      {stage === "email" && (
        <form className="flex flex-col gap-5" onSubmit={sendCode} noValidate>
          <StepTitle title="Sign in">We email you a one-time code. There is no password.</StepTitle>
          <Field id="signin-email" label="Work email" error={error}>
            <Input
              id="signin-email"
              type="email"
              inputMode="email"
              autoComplete="email"
              value={email}
              aria-invalid={error ? true : undefined}
              aria-describedby={error ? "signin-email-error" : undefined}
              onChange={(e) => setEmail(e.target.value)}
              autoFocus
            />
          </Field>
          <Button type="submit" variant="primary" size="lg" className="w-full" disabled={busy}>
            <Icon name="mail-send-line" size={16} />
            {busy ? "Sending…" : "Email me a code"}
          </Button>
          <p className="m-0 text-[13px] leading-5 text-muted">
            New to Context?{" "}
            <Link to="/request-access" className="font-medium text-ink underline decoration-line underline-offset-4 hover:decoration-ink">
              Request access
            </Link>{" "}
            or{" "}
            <a href="/demo" className="font-medium text-ink underline decoration-line underline-offset-4 hover:decoration-ink">
              try a fictional patient
            </a>
            .
          </p>
        </form>
      )}
      {stage === "code" && (
        <form className="flex flex-col gap-5" onSubmit={verify} noValidate>
          <StepTitle icon="mail-check-line" iconClass="text-green-icon" title="Check your email">
            If <strong className="font-medium text-ink">{email.trim()}</strong> belongs to a verified clinician, a 6-digit code is on its way. It works for 10 minutes.
          </StepTitle>
          <Field id="signin-code" label="Code from the email" error={error}>
            <Input
              id="signin-code"
              inputMode="numeric"
              autoComplete="one-time-code"
              maxLength={6}
              placeholder="······"
              className={codeClass}
              value={code}
              aria-invalid={error ? true : undefined}
              aria-describedby={error ? "signin-code-error" : undefined}
              onChange={(e) => setCode(e.target.value.replace(/\D/g, ""))}
              autoFocus
            />
          </Field>
          <Button type="submit" variant="primary" size="lg" className="w-full" disabled={busy}>
            {busy ? "Checking…" : "Continue"}
          </Button>
          <StepLinks>
            <button type="button" className={cn(linkButtonClass, "text-muted no-underline hover:text-ink")} onClick={() => { setStage("email"); setCode(""); setError(""); }}>
              <Icon name="arrow-left-line" size={14} /> Use another email
            </button>
            <button type="button" className={linkButtonClass} disabled={busy} onClick={() => void sendCode()}>
              Send a new code
            </button>
          </StepLinks>
        </form>
      )}
      {(stage === "enrol" || stage === "verify") && <StepUp mode={stage} onDone={afterSession} />}
      {stage === "pending" && (
        <div className="flex flex-col gap-5">
          <StepTitle icon={verification === "suspended" ? "forbid-line" : "hourglass-line"} iconClass={verification === "suspended" ? "text-red-icon" : "text-amber-icon"}
            title={verification === "lapsed" ? "Your yearly licence check has run out" : verification === "suspended" ? "Your access is paused" : "Your access is being checked"}>
            {verification === "lapsed"
              ? "Send your current licence to MARMAR to continue with patient records. Until it is checked again you can try Context with fictional patients."
              : verification === "suspended"
                ? "Your access to patient records is paused. Contact MARMAR support. You can still try Context with fictional patients."
                : "MARMAR verifies your licence before you can see real patients. We email you as soon as it is done. Until then you can try Context with fictional patients."}
          </StepTitle>
          <Button asChild variant="primary" size="lg" className="w-full no-underline">
            <a href="/demo">
              <Icon name="user-heart-line" size={16} /> Try a fictional patient
            </a>
          </Button>
        </div>
      )}
    </AuthFrame>
  );
}