import { useEffect, useRef, useState, type FormEvent } from "react"; import { ApiError, errorCode } from "../api/client"; import type { SignBody } from "../api/types"; import type { Factor } from "../auth/types"; import { useAuth } from "../auth/useAuth"; import { errorText } from "../lib/format"; import { applyKey, type KeypadKey } from "../lib/keypad"; import { Keypad } from "./Keypad"; import { Sheet } from "./Sheet"; const PIN_MAX = 12; interface SignatureSheetProps { /** What the signature means, from the rule. */ meaning: string; sub?: string | undefined; /** Sends the step with the signature. A wrong secret keeps the sheet open. */ onSign: (sign: SignBody) => Promise; /** Any other failure. The caller closes the sheet. */ onFail: (err: unknown) => void; onCancel: () => void; } function attemptsLeft(err: unknown): number | null { if (err instanceof ApiError && err.body && typeof err.body === "object" && "attempts_left" in err.body) { const n = Number((err.body as { attempts_left: unknown }).attempts_left); return Number.isFinite(n) ? n : null; } return null; } /** Signs a step with the user's PIN or password. */ export function SignatureSheet({ meaning, sub, onSign, onFail, onCancel }: SignatureSheetProps) { const auth = useAuth(); const me = auth.me; const hasPin = me ? me.pin.set && !me.pin.disabled : false; const [factor, setFactor] = useState(hasPin ? "pin" : "password"); const [secret, setSecret] = useState(""); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const [left, setLeft] = useState(null); const [missed, setMissed] = useState(false); const inputRef = useRef(null); useEffect(() => { inputRef.current?.focus(); }, [factor]); const switchTo = (f: Factor) => { setFactor(f); setSecret(""); setError(null); }; const submit = async (e: FormEvent) => { e.preventDefault(); if (!secret || busy) return; setBusy(true); setError(null); try { await onSign({ factor, secret }); } catch (err) { const code = errorCode(err); if (code === "session_ended") { // The auth layer signs out on this 401 too. Make sure, with the server's words. auth.endSession(errorText(err)); return; } if (code === "wrong_secret" || code === "bad_secret") { setError(errorText(err)); setLeft(attemptsLeft(err)); setMissed(true); setSecret(""); setBusy(false); inputRef.current?.focus(); return; } onFail(err); } }; const onKey = (k: KeypadKey) => setSecret((v) => applyKey(v, k, PIN_MAX)); return (
This signature means
{meaning}
You are signing as {me?.user.display_name ?? "yourself"}.
{error ? (
{error} {left !== null ?
{left === 1 ? "1 attempt left." : `${left} attempts left.`}
: null} {missed ?
Three wrong attempts end the session.
: null}
) : null}
{factor === "pin" ? ( <> ) : ( )}
{factor === "pin" ? ( ) : hasPin ? ( ) : null}
); }