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,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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user