155 lines
5.1 KiB
TypeScript
155 lines
5.1 KiB
TypeScript
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<void>;
|
||
|
|
/** 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<Factor>(hasPin ? "pin" : "password");
|
||
|
|
const [secret, setSecret] = useState("");
|
||
|
|
const [busy, setBusy] = useState(false);
|
||
|
|
const [error, setError] = useState<string | null>(null);
|
||
|
|
const [left, setLeft] = useState<number | null>(null);
|
||
|
|
const [missed, setMissed] = useState(false);
|
||
|
|
const inputRef = useRef<HTMLInputElement>(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 (
|
||
|
|
<Sheet title="Sign" sub={sub} onClose={onCancel}>
|
||
|
|
<div className="sigmean">
|
||
|
|
<div className="eyebrow">This signature means</div>
|
||
|
|
<div className="tt">{meaning}</div>
|
||
|
|
</div>
|
||
|
|
<div className="small">
|
||
|
|
You are signing as <b>{me?.user.display_name ?? "yourself"}</b>.
|
||
|
|
</div>
|
||
|
|
{error ? (
|
||
|
|
<div className="callout warn flush" role="alert">
|
||
|
|
{error}
|
||
|
|
{left !== null ? <div className="small">{left === 1 ? "1 attempt left." : `${left} attempts left.`}</div> : null}
|
||
|
|
{missed ? <div className="small">Three wrong attempts end the session.</div> : null}
|
||
|
|
</div>
|
||
|
|
) : null}
|
||
|
|
<form className="auth-form" onSubmit={submit} noValidate autoComplete="off">
|
||
|
|
{factor === "pin" ? (
|
||
|
|
<>
|
||
|
|
<label className="f">
|
||
|
|
PIN
|
||
|
|
<input
|
||
|
|
ref={inputRef}
|
||
|
|
className="in big"
|
||
|
|
name="sign-pin"
|
||
|
|
type="password"
|
||
|
|
inputMode="numeric"
|
||
|
|
autoComplete="off"
|
||
|
|
maxLength={PIN_MAX}
|
||
|
|
value={secret}
|
||
|
|
onChange={(e) => setSecret(e.target.value.replace(/\D/g, "").slice(0, PIN_MAX))}
|
||
|
|
/>
|
||
|
|
</label>
|
||
|
|
<Keypad onKey={onKey} disabled={busy} label="Keypad, signature PIN" />
|
||
|
|
</>
|
||
|
|
) : (
|
||
|
|
<label className="f">
|
||
|
|
Password
|
||
|
|
<input
|
||
|
|
ref={inputRef}
|
||
|
|
className="in"
|
||
|
|
name="sign-password"
|
||
|
|
type="password"
|
||
|
|
autoComplete="current-password"
|
||
|
|
value={secret}
|
||
|
|
onChange={(e) => setSecret(e.target.value)}
|
||
|
|
/>
|
||
|
|
</label>
|
||
|
|
)}
|
||
|
|
<div className="sheet-acts">
|
||
|
|
{factor === "pin" ? (
|
||
|
|
<button className="lnk small" type="button" onClick={() => switchTo("password")}>
|
||
|
|
Use your password instead
|
||
|
|
</button>
|
||
|
|
) : hasPin ? (
|
||
|
|
<button className="lnk small" type="button" onClick={() => switchTo("pin")}>
|
||
|
|
Use your PIN instead
|
||
|
|
</button>
|
||
|
|
) : null}
|
||
|
|
<span className="grow" />
|
||
|
|
<button className="btn ghost" type="button" onClick={onCancel}>
|
||
|
|
Cancel
|
||
|
|
</button>
|
||
|
|
<button className="btn pri" type="submit" disabled={busy || !secret}>
|
||
|
|
{busy ? "Signing" : "Sign"}
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
</form>
|
||
|
|
</Sheet>
|
||
|
|
);
|
||
|
|
}
|