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>
);
}