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,36 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { PacketCard } from "../api/types";
|
||||
import { Card } from "./Card";
|
||||
|
||||
export interface ColumnDef {
|
||||
key: string;
|
||||
title: string;
|
||||
cards: PacketCard[];
|
||||
/** Shown when the column is empty. */
|
||||
empty: ReactNode;
|
||||
}
|
||||
|
||||
/** The old column(): a titled column of cards with a count, or the empty note. */
|
||||
export function Column({ title, cards, empty }: Omit<ColumnDef, "key">) {
|
||||
const id = `col-${title.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`;
|
||||
return (
|
||||
<section className="col" aria-labelledby={id}>
|
||||
<h3 className="col-h" id={id}>
|
||||
{title}
|
||||
<span className="c">{cards.length}</span>
|
||||
</h3>
|
||||
{cards.length ? cards.map((p) => <Card key={p.id} p={p} />) : <div className="empty">{empty}</div>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** A row of columns. */
|
||||
export function Board({ columns }: { columns: ColumnDef[] }) {
|
||||
return (
|
||||
<div className="board">
|
||||
{columns.map((c) => (
|
||||
<Column key={c.key} title={c.title} cards={c.cards} empty={c.empty} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Link } from "react-router";
|
||||
import type { PacketCard } from "../api/types";
|
||||
import { flagClass, jobName, openCoCount, pillClass, statusLabel } from "../lib/packets";
|
||||
|
||||
/** One packet on a board. The old card(): job name and pill, title, department, meta, flag line. */
|
||||
export function Card({ p, extra }: { p: PacketCard; extra?: ReactNode }) {
|
||||
const shorts = p.open_shorts || 0;
|
||||
const cos = openCoCount(p);
|
||||
return (
|
||||
<Link className="card" to={`/packets/${p.id}`} aria-label={`${jobName(p)}, ${statusLabel(p.status)}`}>
|
||||
<div className="r1">
|
||||
<span className="job">{jobName(p)}</span>
|
||||
<span className={pillClass(p.status)}>{statusLabel(p.status)}</span>
|
||||
</div>
|
||||
<div className="tt">{p.title || "Untitled"}</div>
|
||||
<div className="small dept">{p.dept.name}</div>
|
||||
<div className="meta">
|
||||
<span>
|
||||
Dwg <b className="mono">{p.drawing || "-"}</b>
|
||||
{p.rev ? <> R{p.rev}</> : null}
|
||||
</span>
|
||||
{p.wo_count !== undefined ? (
|
||||
<span>
|
||||
<b>{p.wo_count}</b> WO
|
||||
</span>
|
||||
) : null}
|
||||
{p.station ? (
|
||||
<span>
|
||||
<b>{p.station.name}</b>
|
||||
{p.welder ? ` · ${p.welder.display_name}` : ""}
|
||||
</span>
|
||||
) : null}
|
||||
{p.status === "complete" && p.routed_to ? (
|
||||
<span>
|
||||
To <b>{p.routed_to === "databook" ? "Clerical (data book)" : "Shipping"}</b>
|
||||
</span>
|
||||
) : null}
|
||||
{shorts ? <span className="pill st-review">{shorts} short</span> : null}
|
||||
{cos ? <span className="pill st-review">CO {cos}</span> : null}
|
||||
</div>
|
||||
{p.flag ? <div className={`small ${flagClass(p.flag)}`}>⚑ {p.flag}</div> : null}
|
||||
{extra}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { PickUnit, type UnitPick } from "./PickUnit";
|
||||
|
||||
export type BoardView = "board" | "list";
|
||||
|
||||
interface FindBarProps {
|
||||
q: string;
|
||||
onQ: (q: string) => void;
|
||||
pick: UnitPick;
|
||||
onPick: (v: UnitPick) => void;
|
||||
view: BoardView;
|
||||
onView: (v: BoardView) => void;
|
||||
/** "Showing n packets". */
|
||||
count: number | null;
|
||||
}
|
||||
|
||||
/** Find a job: the search box, the project then unit picker, and the Board | List toggle. */
|
||||
export function FindBar({ q, onQ, pick, onPick, view, onView, count }: FindBarProps) {
|
||||
return (
|
||||
<section className="fltbar" aria-label="Find jobs">
|
||||
<div className="fltrow">
|
||||
<label className="f fltq">
|
||||
<span>Find a job</span>
|
||||
<input
|
||||
className="in"
|
||||
type="search"
|
||||
placeholder="Project, unit, lot, drawing, WO, customer, PO, title"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
value={q}
|
||||
onChange={(e) => onQ(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape" && q) {
|
||||
e.stopPropagation();
|
||||
onQ("");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<div className="f fltp">
|
||||
<PickUnit value={pick} onChange={onPick} />
|
||||
</div>
|
||||
<div className="fltbtns">
|
||||
<div className="seg" role="group" aria-label="View">
|
||||
<button type="button" aria-pressed={view === "board"} onClick={() => onView("board")}>
|
||||
Board
|
||||
</button>
|
||||
<button type="button" aria-pressed={view === "list"} onClick={() => onView("list")}>
|
||||
List
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{count !== null ? (
|
||||
<div className="fltrow2">
|
||||
<span className="grow" />
|
||||
<span className="small muted">
|
||||
Showing <b>{count}</b> packet{count === 1 ? "" : "s"}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export interface Kpi {
|
||||
n: ReactNode;
|
||||
label: string;
|
||||
/** Turns pink when above zero. */
|
||||
alert?: boolean;
|
||||
}
|
||||
|
||||
/** The old kpis(): number tiles. Zero is grey, an alert above zero is pink. */
|
||||
export function Kpis({ items, label = "Numbers" }: { items: Kpi[]; label?: string }) {
|
||||
return (
|
||||
<div className="kpis" role="group" aria-label={label}>
|
||||
{items.map((k) => {
|
||||
const zero = String(k.n) === "0";
|
||||
const cls = ["kpi", k.alert && !zero ? "hot" : "", zero ? "zero" : ""].filter(Boolean).join(" ");
|
||||
return (
|
||||
<div key={k.label} className={cls} aria-label={`${k.label}: ${String(k.n)}`}>
|
||||
<div className="n">{k.n}</div>
|
||||
<div className="l">{k.label}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { useEffect, useId, useState } from "react";
|
||||
import { projectsApi } from "../api/projects";
|
||||
import type { Project, ProjectRef, Unit } from "../api/types";
|
||||
import { errorText } from "../lib/format";
|
||||
import { projectPillClass, PROJECT_STATUS_LABEL } from "../lib/packets";
|
||||
|
||||
export interface UnitPick {
|
||||
project: ProjectRef | null;
|
||||
unit: Unit | null;
|
||||
}
|
||||
|
||||
interface PickUnitProps {
|
||||
value: UnitPick;
|
||||
onChange: (v: UnitPick) => void;
|
||||
/** A packet must go in a unit: no "All units" choice, and closed units are left out. */
|
||||
requireUnit?: boolean;
|
||||
/** Label of the first step. */
|
||||
label?: string;
|
||||
}
|
||||
|
||||
/** Two steps: find the project, then pick one of its units. */
|
||||
export function PickUnit({ value, onChange, requireUnit = false, label = "Project" }: PickUnitProps) {
|
||||
const id = useId();
|
||||
const [q, setQ] = useState("");
|
||||
const [query, setQuery] = useState("");
|
||||
const [hits, setHits] = useState<Project[] | null>(null);
|
||||
const [units, setUnits] = useState<Unit[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const projectId = value.project?.id ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
const t = window.setTimeout(() => setQuery(q.trim()), 250);
|
||||
return () => window.clearTimeout(t);
|
||||
}, [q]);
|
||||
|
||||
// Step one: projects matching the search, while no project is chosen.
|
||||
useEffect(() => {
|
||||
if (projectId !== null) return;
|
||||
const ctl = new AbortController();
|
||||
projectsApi
|
||||
.list(query, "active", ctl.signal)
|
||||
.then((list) => {
|
||||
if (!ctl.signal.aborted) setHits(list);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (!ctl.signal.aborted) setError(errorText(err));
|
||||
});
|
||||
return () => ctl.abort();
|
||||
}, [query, projectId]);
|
||||
|
||||
// Step two: the chosen project's units.
|
||||
useEffect(() => {
|
||||
if (projectId === null) {
|
||||
setUnits(null);
|
||||
return;
|
||||
}
|
||||
const ctl = new AbortController();
|
||||
projectsApi
|
||||
.units(projectId, "", ctl.signal)
|
||||
.then((list) => {
|
||||
if (!ctl.signal.aborted) setUnits(list);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (!ctl.signal.aborted) setError(errorText(err));
|
||||
});
|
||||
return () => ctl.abort();
|
||||
}, [projectId]);
|
||||
|
||||
const choose = (p: Project) => {
|
||||
setError(null);
|
||||
onChange({ project: { id: p.id, number: p.number, name: p.name, customer: p.customer, po: p.po, status: p.status, due: p.due }, unit: null });
|
||||
};
|
||||
|
||||
if (!value.project) {
|
||||
const shown = (hits ?? []).slice(0, 8);
|
||||
return (
|
||||
<div className="pick">
|
||||
<label className="f">
|
||||
{label}
|
||||
<input
|
||||
className="in"
|
||||
type="search"
|
||||
placeholder="Find a project: number, name, customer, PO"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
{error ? <div className="small due over">{error}</div> : null}
|
||||
{hits === null ? null : shown.length ? (
|
||||
<div className="projhits" role="group" aria-label="Projects found">
|
||||
{shown.map((p) => (
|
||||
<button key={p.id} type="button" className="projhit" onClick={() => choose(p)}>
|
||||
<b className="mono">{p.number}</b> {p.name ?? ""}
|
||||
<span className="muted small">{p.customer ?? ""}</span>
|
||||
<span className={projectPillClass(p.status)}>{PROJECT_STATUS_LABEL[p.status]}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="small muted">{query ? `Nothing matches "${query}".` : "No open projects yet."}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const list = (units ?? []).filter((u) => !requireUnit || u.status !== "closed" || u.id === value.unit?.id);
|
||||
return (
|
||||
<div className="pick">
|
||||
<div className="pickp">
|
||||
<span className="small muted">{label}</span>
|
||||
<b className="mono">{value.project.number}</b> <span>{value.project.name ?? ""}</span>
|
||||
<button className="lnk small" type="button" onClick={() => onChange({ project: null, unit: null })}>
|
||||
Change
|
||||
</button>
|
||||
</div>
|
||||
<label className="f" htmlFor={`${id}-unit`}>
|
||||
Unit
|
||||
</label>
|
||||
{units === null ? (
|
||||
<div className="small muted">Loading units</div>
|
||||
) : list.length === 0 ? (
|
||||
<div className="small muted">This project has no open units. Add one from the project page first.</div>
|
||||
) : (
|
||||
<select
|
||||
id={`${id}-unit`}
|
||||
className="in"
|
||||
value={value.unit ? String(value.unit.id) : ""}
|
||||
onChange={(e) => {
|
||||
const u = list.find((x) => String(x.id) === e.target.value) ?? null;
|
||||
onChange({ project: value.project, unit: u });
|
||||
}}
|
||||
>
|
||||
<option value="">{requireUnit ? "Choose a unit" : "All units"}</option>
|
||||
{list.map((u) => (
|
||||
<option key={u.id} value={String(u.id)}>
|
||||
{u.number}
|
||||
{u.name ? ` · ${u.name}` : ""}
|
||||
{u.status === "hold" ? " (on hold)" : u.status === "closed" ? " (closed)" : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
{error ? <div className="small due over">{error}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { Sheet } from "./Sheet";
|
||||
|
||||
export interface ReasonOptions {
|
||||
title: string;
|
||||
sub?: string | undefined;
|
||||
/** The field label. */
|
||||
label?: string;
|
||||
/** A line under the title. */
|
||||
note?: string | undefined;
|
||||
placeholder?: string | undefined;
|
||||
/** Shortest reason accepted. 0 makes the field optional. */
|
||||
min?: number;
|
||||
max?: number;
|
||||
submitLabel?: string;
|
||||
danger?: boolean;
|
||||
}
|
||||
|
||||
interface ReasonSheetProps extends ReasonOptions {
|
||||
onSubmit: (reason: string) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
/** Asks for a reason, or an optional note, before a step. */
|
||||
export function ReasonSheet({
|
||||
title,
|
||||
sub,
|
||||
label = "Reason",
|
||||
note,
|
||||
placeholder,
|
||||
min = 1,
|
||||
max = 500,
|
||||
submitLabel = "Continue",
|
||||
danger = false,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
}: ReasonSheetProps) {
|
||||
const [text, setText] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const submit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
const t = text.trim();
|
||||
if (t.length < min) {
|
||||
setError(min <= 1 ? "Give a reason." : `Give a reason of at least ${min} characters.`);
|
||||
return;
|
||||
}
|
||||
onSubmit(t);
|
||||
};
|
||||
|
||||
return (
|
||||
<Sheet title={title} sub={sub} onClose={onCancel}>
|
||||
{note ? <div className="small">{note}</div> : null}
|
||||
<form className="stack" onSubmit={submit} noValidate>
|
||||
<label className="f">
|
||||
{label}
|
||||
<input className="in" value={text} maxLength={max} placeholder={placeholder} onChange={(e) => setText(e.target.value)} />
|
||||
</label>
|
||||
{error ? (
|
||||
<div className="callout warn flush" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="sheet-acts">
|
||||
<button className="btn ghost" type="button" onClick={onCancel}>
|
||||
Cancel
|
||||
</button>
|
||||
<button className={danger ? "btn pink" : "btn pri"} type="submit">
|
||||
{submitLabel}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useEffect, useId, useRef, type ReactNode } from "react";
|
||||
|
||||
interface SheetProps {
|
||||
title: string;
|
||||
/** Small text next to the title, like the job name. */
|
||||
sub?: string | undefined;
|
||||
onClose: () => void;
|
||||
children: ReactNode;
|
||||
/** The old sheets were 560 px for short forms and 720 px or more for long ones. */
|
||||
wide?: boolean;
|
||||
}
|
||||
|
||||
/** Open sheets, innermost last. Only the innermost one answers Escape. */
|
||||
const open: string[] = [];
|
||||
|
||||
/** A modal sheet over a scrim. Escape and a click on the scrim close it. The first field gets the focus. */
|
||||
export function Sheet({ title, sub, onClose, children, wide = false }: SheetProps) {
|
||||
const id = useId();
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const closeRef = useRef(onClose);
|
||||
useEffect(() => {
|
||||
closeRef.current = onClose;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
const first = el?.querySelector<HTMLElement>("input:not([type=hidden]):not([type=file]):not([disabled]), select, textarea");
|
||||
(first ?? el)?.focus();
|
||||
open.push(id);
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && open[open.length - 1] === id) {
|
||||
e.stopPropagation();
|
||||
closeRef.current();
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
document.removeEventListener("keydown", onKey);
|
||||
const i = open.lastIndexOf(id);
|
||||
if (i >= 0) open.splice(i, 1);
|
||||
};
|
||||
}, [id]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="scrim"
|
||||
onMouseDown={(e) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
<div ref={ref} className={wide ? "sheet" : "sheet narrow"} role="dialog" aria-modal="true" aria-labelledby={id} tabIndex={-1}>
|
||||
<div className="sec-h flush">
|
||||
<h2 id={id}>{title}</h2>
|
||||
{sub ? <span className="ct">{sub}</span> : null}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { useCallback, useMemo, useState, type ReactNode } from "react";
|
||||
import { ApiError, errorCode } from "../api/client";
|
||||
import type { Guarded, SignBody } from "../api/types";
|
||||
import { ReasonSheet, type ReasonOptions } from "./ReasonSheet";
|
||||
import { SignatureSheet } from "./SignatureSheet";
|
||||
|
||||
type Ask =
|
||||
| { kind: "reason"; opts: ReasonOptions; resolve: (r: string | null) => void }
|
||||
| {
|
||||
kind: "sign";
|
||||
meaning: string;
|
||||
sub: string | undefined;
|
||||
attempt: (sign: SignBody) => Promise<unknown>;
|
||||
resolve: (r: { ok: true; value: unknown } | { ok: false }) => void;
|
||||
reject: (err: unknown) => void;
|
||||
};
|
||||
|
||||
export interface RunOptions {
|
||||
/** The signature meaning when the step is known to need one. Otherwise the sheet opens on 400 signature_required. */
|
||||
sign?: string | undefined;
|
||||
/** Shown next to the sheet titles, like the job name. */
|
||||
sub?: string | undefined;
|
||||
}
|
||||
|
||||
export interface Guard {
|
||||
/**
|
||||
* Runs a call. On 400 it_reason_required it asks for the IT reason and retries with `it_reason`.
|
||||
* On 400 signature_required it opens the signature sheet and retries with `sign`.
|
||||
* Resolves undefined when the user cancels. Other errors are thrown.
|
||||
*/
|
||||
run: <T>(fn: (g: Guarded) => Promise<T>, opts?: RunOptions) => Promise<T | undefined>;
|
||||
/** Asks for a reason. Null when cancelled. */
|
||||
askReason: (opts: ReasonOptions) => Promise<string | null>;
|
||||
/** The open sheet, if any. Render it once in the screen. */
|
||||
sheet: ReactNode;
|
||||
}
|
||||
|
||||
export const IT_REASON: ReasonOptions = {
|
||||
title: "IT reason",
|
||||
label: "Why IT is making this change",
|
||||
note: "You are changing this as IT. The reason is recorded with the change.",
|
||||
min: 8,
|
||||
submitLabel: "Continue",
|
||||
};
|
||||
|
||||
function meaningOf(err: unknown): string {
|
||||
if (err instanceof ApiError && err.body && typeof err.body === "object" && "meaning" in err.body) {
|
||||
const m = (err.body as { meaning: unknown }).meaning;
|
||||
if (typeof m === "string" && m) return m;
|
||||
}
|
||||
return "Sign this step";
|
||||
}
|
||||
|
||||
/** The IT reason and signature retries, as sheets. */
|
||||
export function useGuard(): Guard {
|
||||
const [ask, setAsk] = useState<Ask | null>(null);
|
||||
|
||||
const askReason = useCallback(
|
||||
(opts: ReasonOptions) =>
|
||||
new Promise<string | null>((resolve) => {
|
||||
setAsk({ kind: "reason", opts, resolve });
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const askSign = useCallback(
|
||||
<T,>(meaning: string, sub: string | undefined, attempt: (sign: SignBody) => Promise<T>) =>
|
||||
new Promise<{ ok: true; value: T } | { ok: false }>((resolve, reject) => {
|
||||
setAsk({
|
||||
kind: "sign",
|
||||
meaning,
|
||||
sub,
|
||||
attempt,
|
||||
resolve: resolve as (r: { ok: true; value: unknown } | { ok: false }) => void,
|
||||
reject,
|
||||
});
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const run = useCallback(
|
||||
async <T,>(fn: (g: Guarded) => Promise<T>, opts: RunOptions = {}): Promise<T | undefined> => {
|
||||
let itReason: string | undefined;
|
||||
let meaning = opts.sign;
|
||||
for (let round = 0; round < 4; round++) {
|
||||
const call = (sign?: SignBody) => {
|
||||
const g: Guarded = {};
|
||||
if (itReason) g.it_reason = itReason;
|
||||
if (sign) g.sign = sign;
|
||||
return fn(g);
|
||||
};
|
||||
try {
|
||||
if (meaning) {
|
||||
const r = await askSign(meaning, opts.sub, call);
|
||||
return r.ok ? r.value : undefined;
|
||||
}
|
||||
return await call();
|
||||
} catch (err) {
|
||||
const code = errorCode(err);
|
||||
if (code === "it_reason_required" && !itReason) {
|
||||
const r = await askReason({ ...IT_REASON, sub: opts.sub });
|
||||
if (r === null) return undefined;
|
||||
itReason = r;
|
||||
continue;
|
||||
}
|
||||
if (code === "signature_required" && !meaning) {
|
||||
meaning = meaningOf(err);
|
||||
continue;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
[askReason, askSign],
|
||||
);
|
||||
|
||||
let sheet: ReactNode = null;
|
||||
if (ask?.kind === "reason") {
|
||||
sheet = (
|
||||
<ReasonSheet
|
||||
{...ask.opts}
|
||||
onSubmit={(r) => {
|
||||
setAsk(null);
|
||||
ask.resolve(r);
|
||||
}}
|
||||
onCancel={() => {
|
||||
setAsk(null);
|
||||
ask.resolve(null);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
} else if (ask?.kind === "sign") {
|
||||
sheet = (
|
||||
<SignatureSheet
|
||||
meaning={ask.meaning}
|
||||
sub={ask.sub}
|
||||
onSign={async (sign) => {
|
||||
const value = await ask.attempt(sign);
|
||||
setAsk(null);
|
||||
ask.resolve({ ok: true, value });
|
||||
}}
|
||||
onFail={(err) => {
|
||||
setAsk(null);
|
||||
ask.reject(err);
|
||||
}}
|
||||
onCancel={() => {
|
||||
setAsk(null);
|
||||
ask.resolve({ ok: false });
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return useMemo(() => ({ run, askReason, sheet }), [run, askReason, sheet]);
|
||||
}
|
||||
Reference in New Issue
Block a user