Phase 2: core domain, policy engine, packet API, Clerical screen
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]>
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user