Files
PacketStation/frontend/src/components/SignatureSheet.tsx
T

155 lines
5.1 KiB
TypeScript
Raw Normal View History

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