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

const svgUrl = (svg: string) => `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
const groups = (secret: string) => secret.replace(/(.{4})/g, "$1 ").trim();

/**
 * The second factor (ACC-7): enrol an authenticator app once (its first code turns it on and
 * shows ten recovery codes, once), then a 6-digit code or a recovery code each time.
 */
export function StepUp({ mode, onDone }: { mode: "enrol" | "verify"; onDone: (s: ClinicianSession) => void }) {
  const [enrolment, setEnrolment] = useState<TotpEnrolment | null>(null);
  const [code, setCode] = useState("");
  const [recovery, setRecovery] = useState(false);
  const [saved, setSaved] = useState<{ session: ClinicianSession; codes: string[] } | null>(null);
  const [error, setError] = useState("");
  const [busy, setBusy] = useState(false);
  useEffect(() => {
    if (mode !== "enrol") return;
    clinicianApi.enrolTotp().then(setEnrolment, (e) => setError(errorText(e)));
  }, [mode]);
  const submit = async (e: FormEvent) => {
    e.preventDefault();
    const value = code.trim();
    if (recovery ? value.length < 8 : !/^\d{6}$/.test(value)) {
      setError(recovery ? "Enter one of your recovery codes." : "Enter the 6-digit code from your authenticator app.");
      return;
    }
    setBusy(true);
    setError("");
    try {
      if (mode === "enrol") {
        const confirmed = await clinicianApi.confirmTotp(value);
        setSaved({ session: confirmed.session, codes: confirmed.recoveryCodes });
      } else onDone(await clinicianApi.verifyTotp(value));
    } catch (err) {
      setError(errorText(err));
      setCode("");
    } finally {
      setBusy(false);
    }
  };
  if (saved)
    return (
      <div className="flex flex-col gap-5">
        <StepTitle icon="key-2-line" iconClass="text-amber-icon" title="Save your recovery codes">
          Each code works once, if you lose your phone. Keep them somewhere safe, away from this computer. MARMAR shows them only now.
        </StepTitle>
        <ol aria-label="Recovery codes" className="m-0 grid list-none grid-cols-2 gap-x-4 gap-y-2 rounded-xl bg-tile p-4">
          {saved.codes.map((c) => (
            <li key={c}>
              <code className="font-mono text-[13.5px] tracking-[0.04em] text-ink">{c}</code>
            </li>
          ))}
        </ol>
        <Button variant="primary" size="lg" className="w-full" onClick={() => onDone(saved.session)}>
          I saved them, continue
        </Button>
      </div>
    );
  return (
    <form className="flex flex-col gap-5" onSubmit={submit} noValidate>
      {mode === "enrol" ? (
        <>
          <StepTitle icon="smartphone-line" iconClass="text-blue-icon" title="Set up your second step">
            Patient data needs a second step after your email code. Add this key to an authenticator app (such as Google Authenticator or Microsoft Authenticator), then
            enter the code it shows.
          </StepTitle>
          {!enrolment && !error && <Spinner label="Preparing your code" />}
          {enrolment && (
            <div className="flex flex-wrap items-center gap-5 rounded-xl bg-tile p-4">
              {enrolment.qrSvg ? (
                <img className="block size-[132px] rounded-lg bg-white p-2 shadow-card" src={svgUrl(enrolment.qrSvg)} alt="QR code for your authenticator app" width={132} height={132} />
              ) : (
                <span className="flex size-[132px] items-center justify-center rounded-lg bg-white text-subtle shadow-card">
                  <Icon name="qr-code-line" size={28} />
                </span>
              )}
              <div className="flex min-w-0 flex-1 flex-col gap-1.5">
                <span className="text-[12.5px] font-medium text-cell">Or enter this key</span>
                <code aria-label="Setup key" className="font-mono text-[14px] leading-[22px] tracking-[0.04em] break-all text-ink">
                  {groups(enrolment.secret)}
                </code>
                <Hint>
                  Account {enrolment.account} · {enrolment.issuer}
                </Hint>
                <a href={enrolment.otpauthUrl} className={cn(linkButtonClass, "mt-1 text-[13px] no-underline")}>
                  <Icon name="external-link-line" size={14} /> Open in an authenticator app on this device
                </a>
              </div>
            </div>
          )}
        </>
      ) : (
        <StepTitle icon="lock-line" iconClass="text-indigo-icon" title="Enter your code">
          {recovery ? "Enter one of the recovery codes you saved. Each works once." : "Open your authenticator app and enter the 6-digit code for MARMAR Context."}
        </StepTitle>
      )}
      <Field id="totp-code" label={recovery ? "Recovery code" : "6-digit code"} error={error}>
        <Input
          id="totp-code"
          inputMode={recovery ? "text" : "numeric"}
          autoComplete="one-time-code"
          pattern={recovery ? undefined : "[0-9]*"}
          maxLength={recovery ? 20 : 6}
          placeholder={recovery ? undefined : "······"}
          className={recovery ? "font-mono tracking-[0.04em]" : codeClass}
          value={code}
          aria-invalid={error ? true : undefined}
          aria-describedby={error ? "totp-code-error" : undefined}
          onChange={(e) => setCode(recovery ? e.target.value : e.target.value.replace(/\D/g, ""))}
          autoFocus
        />
      </Field>
      <Button type="submit" variant="primary" size="lg" className="w-full" disabled={busy || (mode === "enrol" && !enrolment)}>
        {busy ? "Checking…" : mode === "enrol" ? "Turn on and continue" : "Unlock"}
      </Button>
      {mode === "verify" && (
        <button type="button" className={cn(linkButtonClass, "self-start text-[13px] text-muted no-underline hover:text-ink")} onClick={() => { setRecovery(!recovery); setCode(""); setError(""); }}>
          <Icon name={recovery ? "smartphone-line" : "key-2-line"} size={14} />
          {recovery ? "Use the authenticator app instead" : "Use a recovery code"}
        </button>
      )}
    </form>
  );
}