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
+106 -33
View File
@@ -1,14 +1,39 @@
import { useEffect, useState, type FormEvent, type KeyboardEvent } from "react";
import { adminApi } from "../../auth/api";
import { roleChipClass, roleFromKind, sortRoles } from "../../auth/roles";
import type { AdLookup, AdRefreshResult, AdminUser, GroupRow, PinState } from "../../auth/types";
import { admin9Api, type ReconcileAnswer, type RefreshRequested } from "../../api/admin";
import type { AdLookup, AdminUser, GroupRow, PinState } from "../../auth/types";
import { useAuth } from "../../auth/useAuth";
import { errorText, fmtTime } from "../../lib/format";
import { useLoad, type Load } from "../../lib/useLoad";
import { FeedTab, ResourcesTab } from "./FeedTabs";
import { AccessLogTab } from "./AccessLogTab";
import { ChecklistsTab } from "./ChecklistsTab";
import { DepartmentsTab } from "./DepartmentsTab";
import { NotificationsTab } from "./NotificationsTab";
import { PresenceTab } from "./PresenceTab";
import { ProfilesTab } from "./ProfilesTab";
import { SecurityTab } from "./SecurityTab";
import { StampVerifyTab } from "./StampVerifyTab";
import { StorageTab } from "./StorageTab";
type Tab = "users" | "lookup" | "devices" | "groups" | "attempts" | "feed" | "resources" | "storage";
type Tab =
| "users"
| "lookup"
| "devices"
| "groups"
| "attempts"
| "feed"
| "resources"
| "storage"
| "departments"
| "profiles"
| "checklists"
| "security"
| "accesslog"
| "presence"
| "stampverify"
| "notifications";
const TABS: ReadonlyArray<{ id: Tab; label: string }> = [
{ id: "users", label: "Users" },
@@ -19,6 +44,14 @@ const TABS: ReadonlyArray<{ id: Tab; label: string }> = [
{ id: "feed", label: "Feed" },
{ id: "resources", label: "VISUAL resources" },
{ id: "storage", label: "Storage" },
{ id: "departments", label: "Departments" },
{ id: "profiles", label: "Profiles" },
{ id: "checklists", label: "Checklists" },
{ id: "security", label: "Security" },
{ id: "accesslog", label: "Access log" },
{ id: "presence", label: "Who is in" },
{ id: "stampverify", label: "Stamp check" },
{ id: "notifications", label: "Notifications" },
];
const ROLES_NOTE = "Roles come from AD groups. Ask IT to change them.";
@@ -466,57 +499,82 @@ function InAdPill({ inAd }: { inAd: boolean | null }) {
return <span className="pill st-wait">AD unreachable</span>;
}
function RefreshResult({ r }: { r: AdRefreshResult }) {
type ReportValue = string[] | number | undefined;
const count = (v: ReportValue) => (Array.isArray(v) ? v.length : (v ?? 0));
const names = (v: ReportValue) => (Array.isArray(v) ? v : []);
function ReconcileReportView({ load, onReload }: { load: Load<ReconcileAnswer>; onReload: () => void }) {
if (load.state !== "ok") return <Problem load={load} />;
const { run, report } = load.data;
const r = report ?? {};
const tiles: Array<[number, string, boolean]> = [
[r.groups_checked, "Groups checked", false],
[r.groups_missing.length, "Groups missing", r.groups_missing.length > 0],
[r.users_seen, "Users seen", false],
[r.created, "Created", false],
[r.updated, "Updated", false],
[r.deactivated, "Deactivated", r.deactivated > 0],
[count(r.groups_missing), "Groups missing", count(r.groups_missing) > 0],
[count(r.groups_extra), "Extra groups", false],
[r.users_seen ?? 0, "Users seen", false],
[r.created ?? 0, "Created", false],
[r.updated ?? 0, "Updated", false],
[r.deactivated ?? 0, "Deactivated", (r.deactivated ?? 0) > 0],
[r.sessions_ended ?? 0, "Sessions ended", (r.sessions_ended ?? 0) > 0],
[count(r.emp_missing), "No employee ID", count(r.emp_missing) > 0],
[count(r.emp_duplicate), "Duplicate employee IDs", count(r.emp_duplicate) > 0],
];
return (
<div role="status">
<div className="kpis">
{tiles.map(([n, l, hot]) => (
<div key={l} className={hot ? "kpi hot" : n === 0 ? "kpi zero" : "kpi"}>
<div className="n">{n}</div>
<div className="l">{l}</div>
</div>
))}
<section aria-labelledby="recon-h" className="stack">
<div className="sec-h flush">
<h3 id="recon-h">Latest reconcile</h3>
<span className="ct">
{run ? `${run.status}, finished ${fmtTime(run.finished_at)}` : "Not run yet. It runs nightly at 01:30."}
</span>
<span className="grow" />
<button className="btn sm ghost" type="button" onClick={onReload}>
Show the latest report
</button>
</div>
{r.groups_missing.length ? (
<div className="callout warn">
Missing groups: <span className="mono">{r.groups_missing.join(", ")}</span>
{run ? (
<div className="kpis">
{tiles.map(([n, l, hot]) => (
<div key={l} className={hot ? "kpi hot" : n === 0 ? "kpi zero" : "kpi"}>
<div className="n">{n}</div>
<div className="l">{l}</div>
</div>
))}
</div>
) : null}
{r.emp_missing.length ? (
<div className="callout warn">
No employee ID in AD: <span className="mono">{r.emp_missing.join(", ")}</span>
{names(r.groups_missing).length ? (
<div className="callout warn flush">
Missing groups: <span className="mono">{names(r.groups_missing).join(", ")}</span>
</div>
) : null}
{r.emp_duplicate.length ? (
<div className="callout warn">
Employee IDs used by more than one account: <span className="mono">{r.emp_duplicate.join(", ")}</span>
{names(r.emp_missing).length ? (
<div className="callout warn flush">
No employee ID in AD: <span className="mono">{names(r.emp_missing).join(", ")}</span>
</div>
) : null}
</div>
{names(r.emp_duplicate).length ? (
<div className="callout warn flush">
Employee IDs used by more than one account: <span className="mono">{names(r.emp_duplicate).join(", ")}</span>
</div>
) : null}
</section>
);
}
function GroupsTab() {
const { roleLabel } = useAuth();
const [load, reload] = useLoad((s) => adminApi.groups(s));
const [report, reloadReport] = useLoad((s) => admin9Api.reconcile(s));
const [busy, setBusy] = useState(false);
const [result, setResult] = useState<AdRefreshResult | null>(null);
const [requested, setRequested] = useState<RefreshRequested | null>(null);
const [error, setError] = useState<string | null>(null);
const refresh = async () => {
setBusy(true);
setError(null);
try {
setResult(await adminApi.refresh());
setRequested(await admin9Api.refreshAd());
reload();
reloadReport();
} catch (err) {
setError(errorText(err));
} finally {
@@ -537,7 +595,7 @@ function GroupsTab() {
</span>
<span className="grow" />
<button className="btn sm pri" type="button" disabled={busy} onClick={() => void refresh()}>
{busy ? "Reading AD" : "Refresh from AD now"}
{busy ? "Asking the worker" : "Refresh from AD now"}
</button>
</div>
{error ? (
@@ -545,7 +603,13 @@ function GroupsTab() {
{error}
</div>
) : null}
{result ? <RefreshResult r={result} /> : null}
{requested ? (
<div className="callout flush" role="status">
Refresh requested. The worker runs the AD reconcile within a minute
{requested.pending > 1 ? `, after ${requested.pending - 1} other ${requested.pending === 2 ? "request" : "requests"}` : ""}. Then show the latest report.
</div>
) : null}
<ReconcileReportView load={report} onReload={reloadReport} />
<Problem load={load} />
{map && !map.ad_reachable ? <div className="callout warn">AD is unreachable. Showing the expected groups only.</div> : null}
{map && missing > 0 ? (
@@ -654,7 +718,7 @@ function AttemptsTab() {
);
}
/** /admin: users, AD lookup, devices, the group map and failed sign-ins. Needs a password session. */
/** /admin: users, AD, devices, groups, sign-ins, the feed, storage and the phase 9 tabs. Needs a password session. */
export function AdminPage() {
const { me } = useAuth();
const [tab, setTab] = useState<Tab>("users");
@@ -680,7 +744,8 @@ export function AdminPage() {
return (
<>
{head}
<div className="tabs" role="tablist" aria-label="Admin">
{/* Sixteen tabs: they wrap into rows rather than scroll sideways on a narrow screen. */}
<div className="tabs" role="tablist" aria-label="Admin" style={{ flexWrap: "wrap" }}>
{TABS.map((t) => (
<button
key={t.id}
@@ -704,6 +769,14 @@ export function AdminPage() {
{tab === "feed" ? <FeedTab /> : null}
{tab === "resources" ? <ResourcesTab /> : null}
{tab === "storage" ? <StorageTab /> : null}
{tab === "departments" ? <DepartmentsTab /> : null}
{tab === "profiles" ? <ProfilesTab /> : null}
{tab === "checklists" ? <ChecklistsTab /> : null}
{tab === "security" ? <SecurityTab /> : null}
{tab === "accesslog" ? <AccessLogTab /> : null}
{tab === "presence" ? <PresenceTab /> : null}
{tab === "stampverify" ? <StampVerifyTab /> : null}
{tab === "notifications" ? <NotificationsTab /> : null}
</div>
</>
);