Phase 9: Admin completion, reconcile, notifications, integrity, backups
Admin: Departments (name and stations; add shows the three groups IT must create), Profiles (welder stamp, station limits, active) with a CSV import that previews and applies only when clean, Checklists (weld and plain), Security (session, lockout and rate-limit settings with bounds, alerts for the last 24 hours, the integrity result), Access log with filters and a guarded CSV, Who is in, Stamp check, Notifications, and the employeeID flags. Every Admin change is audited. Worker: ad-reconcile (nightly and on "refresh from AD now"), session-refresh (15 minutes), integrity (seals, asset hashes, missing and orphan files, the audit chain), backup-check, notify-send and notify-digest. Notifications route per event type to AD groups, users, mailboxes or the department Supervisor group, immediately or in a daily digest, over STARTTLS verified against the PESCO CA, with dry run on the test stack. Backups: scripts/backup/ps-backup.sh and ps-restore.sh with a systemd timer at 02:00 America/Denver. Restore rehearsed on the test stack 2026-10-21. CI: pip-audit and npm audit. pypdf 6.21.1 and certifi 2026.10.19. Migration 0010. Closes the app part of I6, M3, O1, O2 and S3. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,231 @@
|
||||
// Admin: the department checklists, one list for weld departments and one for the rest (phase 9).
|
||||
import { useEffect, useState } from "react";
|
||||
import { admin9Api, type ChecklistItem, type ChecklistLists, type ChecklistsAnswer } from "../../api/admin";
|
||||
import { ApiError } from "../../api/client";
|
||||
import { errorText } from "../../lib/format";
|
||||
import { useLoad } from "../../lib/useLoad";
|
||||
import { Notice, Problem, type Msg } from "./common";
|
||||
|
||||
type ListId = "weld" | "plain";
|
||||
const LISTS: ReadonlyArray<{ id: ListId; title: string; one: string }> = [
|
||||
{ id: "weld", title: "Weld departments", one: "weld" },
|
||||
{ id: "plain", title: "Other departments", one: "plain" },
|
||||
];
|
||||
|
||||
const RX_KEY = /^[a-z][a-z0-9_]{0,29}$/;
|
||||
const MAX_ITEMS = 40;
|
||||
const cap = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
|
||||
const PLACEHOLDERS = new Set(["drawing", "rev", "wps"]);
|
||||
|
||||
interface Row extends ChecklistItem {
|
||||
/** True for a key the saved list already has. Its key cannot change, because answers on open stages use it. */
|
||||
saved: boolean;
|
||||
}
|
||||
|
||||
type Draft = Record<ListId, Row[]>;
|
||||
|
||||
function toDraft(v: ChecklistLists): Draft {
|
||||
return {
|
||||
weld: v.weld.map((it) => ({ ...it, saved: true })),
|
||||
plain: v.plain.map((it) => ({ ...it, saved: true })),
|
||||
};
|
||||
}
|
||||
|
||||
/** The first problem with one list, or null. Mirrors the server's rules. */
|
||||
function listProblem(rows: readonly ChecklistItem[], title: string): string | null {
|
||||
if (rows.length === 0) return `${title}: the list needs at least one item.`;
|
||||
if (rows.length > MAX_ITEMS) return `${title}: at most ${MAX_ITEMS} items.`;
|
||||
const seen = new Set<string>();
|
||||
for (const [i, r] of rows.entries()) {
|
||||
const n = i + 1;
|
||||
if (!r.group.trim()) return `${title}, item ${n}: give it a group.`;
|
||||
if (!r.text.trim()) return `${title}, item ${n}: give it a text.`;
|
||||
if (!RX_KEY.test(r.key)) return `${title}, item ${n}: the key is lower case letters, digits and _, starting with a letter.`;
|
||||
if (seen.has(r.key)) return `${title}, item ${n}: the key ${r.key} is used twice.`;
|
||||
seen.add(r.key);
|
||||
for (const m of r.text.matchAll(/\{([^}]*)\}/g)) {
|
||||
if (!PLACEHOLDERS.has(m[1] ?? "")) return `${title}, item ${n}: only {drawing}, {rev} and {wps} can be filled in.`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function ListEditor({
|
||||
id,
|
||||
title,
|
||||
one,
|
||||
rows,
|
||||
onChange,
|
||||
onDefaults,
|
||||
}: {
|
||||
id: ListId;
|
||||
title: string;
|
||||
one: string;
|
||||
rows: Row[];
|
||||
onChange: (rows: Row[]) => void;
|
||||
onDefaults: () => void;
|
||||
}) {
|
||||
const set = (i: number, patch: Partial<Row>) => onChange(rows.map((r, j) => (j === i ? { ...r, ...patch } : r)));
|
||||
const move = (i: number, d: number) => {
|
||||
const j = i + d;
|
||||
if (j < 0 || j >= rows.length) return;
|
||||
const out = [...rows];
|
||||
const a = out[i];
|
||||
const b = out[j];
|
||||
if (!a || !b) return;
|
||||
out[i] = b;
|
||||
out[j] = a;
|
||||
onChange(out);
|
||||
};
|
||||
const add = () => {
|
||||
const last = rows[rows.length - 1];
|
||||
onChange([...rows, { group: last?.group ?? "", key: "", text: "", saved: false }]);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="panel stack" aria-labelledby={`ckl-${id}-h`}>
|
||||
<div className="sec-h flush">
|
||||
<h3 id={`ckl-${id}-h`}>{title}</h3>
|
||||
<span className="ct">{rows.length} items</span>
|
||||
<span className="grow" />
|
||||
<button className="btn sm ghost" type="button" onClick={onDefaults}>
|
||||
{`Use the default ${one} list`}
|
||||
</button>
|
||||
</div>
|
||||
{rows.map((r, i) => {
|
||||
const n = i + 1;
|
||||
return (
|
||||
<div className="frow" key={`${i}-${r.saved ? r.key : "new"}`}>
|
||||
<label className="f">
|
||||
{`${cap(one)} item ${n} group`}
|
||||
<input className="in" value={r.group} maxLength={200} onChange={(e) => set(i, { group: e.target.value })} />
|
||||
</label>
|
||||
<label className="f">
|
||||
{`${cap(one)} item ${n} key`}
|
||||
<input
|
||||
className="in mono"
|
||||
value={r.key}
|
||||
maxLength={30}
|
||||
readOnly={r.saved}
|
||||
title={r.saved ? "A saved key cannot change. Answers on open stages use it." : undefined}
|
||||
onChange={(e) => set(i, { key: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label className="f" style={{ gridColumn: "span 2" }}>
|
||||
{`${cap(one)} item ${n} text`}
|
||||
<input className="in" value={r.text} maxLength={200} onChange={(e) => set(i, { text: e.target.value })} />
|
||||
</label>
|
||||
<div className="end nowrap">
|
||||
<button className="btn sm ghost" type="button" aria-label={`Move ${one} item ${n} up`} disabled={i === 0} onClick={() => move(i, -1)}>
|
||||
Up
|
||||
</button>{" "}
|
||||
<button
|
||||
className="btn sm ghost"
|
||||
type="button"
|
||||
aria-label={`Move ${one} item ${n} down`}
|
||||
disabled={i === rows.length - 1}
|
||||
onClick={() => move(i, 1)}
|
||||
>
|
||||
Down
|
||||
</button>{" "}
|
||||
<button className="btn sm rejb" type="button" aria-label={`Remove ${one} item ${n}`} onClick={() => onChange(rows.filter((_, j) => j !== i))}>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div>
|
||||
<button className="btn sm" type="button" disabled={rows.length >= MAX_ITEMS} onClick={add}>
|
||||
{`Add ${one} item`}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChecklistsTab() {
|
||||
const [load, reload] = useLoad((s) => admin9Api.checklists(s));
|
||||
const [base, setBase] = useState<ChecklistsAnswer | null>(null);
|
||||
const [draft, setDraft] = useState<Draft | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [msg, setMsg] = useState<Msg>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (load.state === "ok") {
|
||||
setBase(load.data);
|
||||
setDraft(toDraft(load.data.value));
|
||||
}
|
||||
}, [load]);
|
||||
|
||||
if (!base || !draft) return <Problem load={load} />;
|
||||
|
||||
const strip = (rows: Row[]): ChecklistItem[] => rows.map((r) => ({ group: r.group.trim(), key: r.key.trim(), text: r.text.trim() }));
|
||||
const savedKeys = new Set([...base.value.weld, ...base.value.plain].map((r) => r.key));
|
||||
|
||||
const save = async () => {
|
||||
const value: ChecklistLists = { weld: strip(draft.weld), plain: strip(draft.plain) };
|
||||
const problem = listProblem(value.weld, "Weld list") ?? listProblem(value.plain, "Other list");
|
||||
if (problem) {
|
||||
setMsg({ ok: false, text: problem });
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setMsg(null);
|
||||
try {
|
||||
const r = await admin9Api.saveChecklists(value, base.version);
|
||||
setBase(r);
|
||||
setDraft(toDraft(r.value));
|
||||
setMsg({ ok: true, text: "Checklists saved. New checklists use them. Open stages keep their answers." });
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 409) {
|
||||
setMsg({ ok: false, text: "Someone else saved the checklists first. Their version is loaded now. Make your change again." });
|
||||
reload();
|
||||
} else {
|
||||
setMsg({ ok: false, text: errorText(err) });
|
||||
}
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const defaults = (id: ListId) =>
|
||||
setDraft((cur) => (cur ? { ...cur, [id]: base.defaults[id].map((it) => ({ ...it, saved: savedKeys.has(it.key) })) } : cur));
|
||||
|
||||
return (
|
||||
<div className="stack">
|
||||
<div className="small muted">
|
||||
Weld departments get the first list, the others the second. Text can use {"{drawing}"}, {"{rev}"} and {"{wps}"}. A removed item drops out of new
|
||||
checklists only.
|
||||
</div>
|
||||
{LISTS.map((l) => (
|
||||
<ListEditor
|
||||
key={l.id}
|
||||
id={l.id}
|
||||
title={l.title}
|
||||
one={l.one}
|
||||
rows={draft[l.id]}
|
||||
onChange={(rows) => setDraft((cur) => (cur ? { ...cur, [l.id]: rows } : cur))}
|
||||
onDefaults={() => defaults(l.id)}
|
||||
/>
|
||||
))}
|
||||
<Notice msg={msg} />
|
||||
<div className="chips">
|
||||
<button className="btn pri" type="button" disabled={busy} onClick={() => void save()}>
|
||||
Save checklists
|
||||
</button>
|
||||
<button
|
||||
className="btn ghost"
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
setDraft(toDraft(base.value));
|
||||
setMsg(null);
|
||||
}}
|
||||
>
|
||||
Discard changes
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user