Phase 9: Admin completion, reconcile, notifications, integrity, backups
ci / frontend (pull_request) Successful in 1m11s
ci / server (pull_request) Successful in 12m2s
ci / image (pull_request) Skipped
ci / e2e (pull_request) Successful in 6m1s

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:
2026-10-21 18:27:09 -06:00
co-authored by Claude Opus 5.5
parent ce28d2f3c6
commit a82e056b6a
116 changed files with 15125 additions and 297 deletions
+178
View File
@@ -0,0 +1,178 @@
// Admin: the access log for everyone, with filters and a CSV download (phase 9).
import { useEffect, useState, type FormEvent } from "react";
import { admin9Api, type AccessLogFilters, type AccessLogRow } from "../../api/admin";
import { errorText, fmtDetail, fmtTime } from "../../lib/format";
import { Notice, type Msg } from "./common";
const PAGE = 200;
/** A datetime-local value as an ISO time, or undefined. */
function iso(local: string): string | undefined {
if (!local) return undefined;
const d = new Date(local);
return Number.isNaN(d.getTime()) ? undefined : d.toISOString();
}
interface Form {
since: string;
until: string;
q: string;
kind: string;
ok: "" | "true" | "false";
ip: string;
}
const EMPTY: Form = { since: "", until: "", q: "", kind: "", ok: "", ip: "" };
function toFilters(f: Form): AccessLogFilters {
const out: AccessLogFilters = {};
const since = iso(f.since);
const until = iso(f.until);
if (since) out.since = since;
if (until) out.until = until;
if (f.q.trim()) out.q = f.q.trim();
if (f.kind.trim()) out.kind = f.kind.trim();
if (f.ok) out.ok = f.ok;
if (f.ip.trim()) out.ip = f.ip.trim();
return out;
}
export function AccessLogTab() {
const [form, setForm] = useState<Form>(EMPTY);
const [filters, setFilters] = useState<AccessLogFilters>({});
const [rows, setRows] = useState<AccessLogRow[]>([]);
const [next, setNext] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [msg, setMsg] = useState<Msg>(null);
const fetchPage = async (f: AccessLogFilters, before: string | null) => {
setLoading(true);
setMsg(null);
try {
const r = await admin9Api.accessLog({ ...f, limit: PAGE, ...(before ? { before } : {}) });
setRows((cur) => (before ? [...cur, ...r.rows] : r.rows));
setNext(r.next_before);
} catch (err) {
setMsg({ ok: false, text: errorText(err) });
} finally {
setLoading(false);
}
};
// The first page, once. Later pages come from the search and the load-more button.
useEffect(() => {
void fetchPage({}, null);
}, []);
const set = (patch: Partial<Form>) => setForm((cur) => ({ ...cur, ...patch }));
const search = (e: FormEvent) => {
e.preventDefault();
const f = toFilters(form);
setFilters(f);
void fetchPage(f, null);
};
const csvHref = admin9Api.accessLogCsvUrl(filters);
return (
<div className="stack">
<form className="panel stack" onSubmit={search} noValidate role="search" aria-label="Access log filters">
<div className="frow">
<label className="f">
From
<input className="in" type="datetime-local" value={form.since} onChange={(e) => set({ since: e.target.value })} />
</label>
<label className="f">
To
<input className="in" type="datetime-local" value={form.until} onChange={(e) => set({ until: e.target.value })} />
</label>
<label className="f">
Name or account
<input className="in" type="search" value={form.q} onChange={(e) => set({ q: e.target.value })} />
</label>
<label className="f">
Kind
<input className="in mono" placeholder="auth.signin, password, pin" value={form.kind} onChange={(e) => set({ kind: e.target.value })} />
</label>
<label className="f">
Result
<select className="in" aria-label="Result" value={form.ok} onChange={(e) => set({ ok: e.target.value === "true" ? "true" : e.target.value === "false" ? "false" : "" })}>
<option value="">All</option>
<option value="true">OK only</option>
<option value="false">Failed only</option>
</select>
</label>
<label className="f">
Address
<input className="in mono" value={form.ip} onChange={(e) => set({ ip: e.target.value })} />
</label>
</div>
<div className="chips">
<button className="btn pri" type="submit" disabled={loading}>
Search access log
</button>
<button
className="btn ghost"
type="button"
onClick={() => {
setForm(EMPTY);
setFilters({});
void fetchPage({}, null);
}}
>
Clear filters
</button>
<a className="btn" href={csvHref} download>
Download CSV
</a>
</div>
<div className="small muted">The CSV has the rows the last search matched, up to 50,000. The download is logged.</div>
</form>
<Notice msg={msg} />
<div className="tw">
<table aria-label="Access log">
<thead>
<tr>
<th>When</th>
<th>Who</th>
<th>Kind</th>
<th>Result</th>
<th>Address</th>
<th>Detail</th>
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={`${r.source}-${r.id}`}>
<td className="small mono nowrap">{fmtTime(r.at)}</td>
<td>
{r.display_name ?? ""} {r.account ? <span className="mono small muted">{r.account}</span> : null}
</td>
<td className="mono small">{r.kind}</td>
<td>{r.ok ? <span className="pill st-ok">OK</span> : <span className="pill st-bad">Failed</span>}</td>
<td className="mono small">{r.ip ?? "-"}</td>
<td className="small">{fmtDetail(r.detail)}</td>
</tr>
))}
{!loading && rows.length === 0 ? (
<tr>
<td colSpan={6} className="muted">
Nothing matches.
</td>
</tr>
) : null}
</tbody>
</table>
</div>
{loading ? <div className="empty slim">Loading</div> : null}
{next && !loading ? (
<div>
<button className="btn sm" type="button" onClick={() => void fetchPage(filters, next)}>
Load older rows
</button>
</div>
) : null}
</div>
);
}