Migration 0003: projects, aliases, renumbers, units, packets and every child table, change orders, assets, documents, ITPs, station limits, department weld flag. Policy package: Principal, PacketState, Action rules, custody, preconditions, CO hold, order of checks, can/blocked/holder on reads. docs/POLICY.md with the tightenings table for Seth. Write pipeline: FOR UPDATE, CAS on status and dept, If-Match row versions, server-assigned numbers, field CAS, version bump, packet log, audit, NOTIFY, signatures, IT reasons, filed lock and HMAC seal. API: /api/packets (header, transitions, work orders, pages, docs, move, remove, BOM, shorts, welds, heat tags, heat table, stage, checklist, clock, warehouse stage, Q.C., change orders, log, seal), /api/projects, /api/units, /api/assets, /_blob. Frontend: Clerical home, projects and units, packet page with pdf.js and QR codes, hand draft, heat map, send to warehouse, department or Q.C. Tests: policy matrix, API, concurrency, schema, projects; Playwright Clerical draft scenario. Verified on docker-1 with the CI stack and the Samba DC. Co-Authored-By: Claude Fable 5.1 <[email protected]>
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>
|
|
);
|
|
}
|