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:
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user