Files
PacketStation/frontend/src/screens/admin/AdminPage.tsx
T
spencermandClaude Opus 5.5 a82e056b6a
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
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]>
2026-10-21 18:27:09 -06:00

784 lines
26 KiB
TypeScript

import { useEffect, useState, type FormEvent, type KeyboardEvent } from "react";
import { adminApi } from "../../auth/api";
import { roleChipClass, roleFromKind, sortRoles } from "../../auth/roles";
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"
| "departments"
| "profiles"
| "checklists"
| "security"
| "accesslog"
| "presence"
| "stampverify"
| "notifications";
const TABS: ReadonlyArray<{ id: Tab; label: string }> = [
{ id: "users", label: "Users" },
{ id: "lookup", label: "Look up AD account" },
{ id: "devices", label: "Devices" },
{ id: "groups", label: "Group map" },
{ id: "attempts", label: "Sign-in attempts" },
{ 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.";
function Problem({ load }: { load: Load<unknown> }) {
if (load.state === "loading") return <div className="empty slim">Loading</div>;
if (load.state === "error") return <div className="callout warn">{load.message}</div>;
return null;
}
function RoleChips({ roles, label }: { roles: readonly string[]; label: string }) {
const { roleLabel } = useAuth();
if (roles.length === 0) return <span className="muted small">No roles</span>;
return (
<ul className="chips plain tight" aria-label={label}>
{sortRoles(roles).map((r) => (
<li key={r} className={roleChipClass(r)}>
{roleLabel(r)}
</li>
))}
</ul>
);
}
function GroupChips({ groups }: { groups: readonly string[] }) {
if (groups.length === 0) return <span className="muted small">No Packet Station groups</span>;
return (
<ul className="chips plain tight" aria-label="Groups">
{groups.map((g) => (
<li key={g} className="chip static mono">
{g}
</li>
))}
</ul>
);
}
function PinPill({ pin }: { pin: PinState }) {
if (!pin.set) return <span className="muted small">None</span>;
if (pin.disabled) return <span className="pill st-bad">Disabled</span>;
return <span className="pill st-ok">Set</span>;
}
/* ---------- Users ---------- */
function UserDetail({ id, onChanged, onClose }: { id: string; onChanged: () => void; onClose: () => void }) {
const [load, reload] = useLoad((s) => adminApi.user(id, s), id);
const [reason, setReason] = useState("");
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null);
const clearPin = async (e: FormEvent) => {
e.preventDefault();
if (reason.trim().length < 8) {
setMsg({ ok: false, text: "Give a reason of at least 8 characters." });
return;
}
setBusy(true);
try {
await adminApi.clearPin(id, reason.trim());
setReason("");
setMsg({ ok: true, text: "PIN cleared. The user sets a new one from Account." });
reload();
onChanged();
} catch (err) {
setMsg({ ok: false, text: errorText(err) });
} finally {
setBusy(false);
}
};
if (load.state !== "ok") {
return (
<div className="panel">
<Problem load={load} />
</div>
);
}
const u = load.data;
return (
<section className="panel stack" aria-labelledby="user-detail-h">
<div className="sec-h">
<h3 id="user-detail-h">{u.display_name}</h3>
<span className="mono small muted">{u.sam_account}</span>
<span className="grow" />
<button className="btn sm ghost" type="button" onClick={onClose}>
Close
</button>
</div>
<dl className="kv">
<div>
<dt>Employee ID</dt>
<dd className="mono">{u.emp_no ?? <span className="muted">Missing</span>}</dd>
</div>
<div>
<dt>Email</dt>
<dd>{u.email ?? "-"}</dd>
</div>
<div>
<dt>Last sign-in</dt>
<dd className="mono small">{fmtTime(u.last_signin_at)}</dd>
</div>
<div>
<dt>Read from AD</dt>
<dd className="mono small">{fmtTime(u.ad_synced_at)}</dd>
</div>
<div>
<dt>Groups{u.groups_source === "cache" ? " (cached, AD unreachable)" : " (live)"}</dt>
<dd>
<GroupChips groups={u.groups} />
</dd>
</div>
<div>
<dt>Roles</dt>
<dd>
<RoleChips roles={u.roles} label="Roles" />
</dd>
</div>
</dl>
<div className="callout flush">{u.note || ROLES_NOTE}</div>
<h3>Sessions</h3>
<div className="tw">
<table>
<thead>
<tr>
<th>Started</th>
<th>Last seen</th>
<th>Sign-in</th>
<th>Device</th>
<th>Address</th>
</tr>
</thead>
<tbody>
{u.sessions.map((s) => (
<tr key={s.id}>
<td className="small mono">{fmtTime(s.created_at)}</td>
<td className="small mono">{fmtTime(s.last_seen_at)}</td>
<td>{s.kind === "pin" ? "PIN" : "Password"}</td>
<td>{s.device?.name ?? "-"}</td>
<td className="mono small">{s.ip ?? "-"}</td>
</tr>
))}
{u.sessions.length === 0 ? (
<tr>
<td colSpan={5} className="muted">
Not signed in.
</td>
</tr>
) : null}
</tbody>
</table>
</div>
<h3>PIN</h3>
<div>
<PinPill pin={u.pin} />
</div>
{u.pin.set ? (
<form className="frow" onSubmit={clearPin} noValidate>
<label className="f">
Reason for clearing the PIN
<input className="in" value={reason} maxLength={300} onChange={(e) => setReason(e.target.value)} />
</label>
<div className="end">
<button className="btn rejb" type="submit" disabled={busy}>
Clear PIN
</button>
</div>
</form>
) : null}
{msg ? (
<div className={msg.ok ? "callout flush" : "callout warn flush"} role={msg.ok ? "status" : "alert"}>
{msg.text}
</div>
) : null}
</section>
);
}
function UsersTab() {
const [q, setQ] = useState("");
const [query, setQuery] = useState("");
const [selected, setSelected] = useState<string | null>(null);
const [load, reload] = useLoad((s) => adminApi.users(query, s), query);
// Search as the admin types, a moment after the last key.
useEffect(() => {
const t = window.setTimeout(() => setQuery(q.trim()), 250);
return () => window.clearTimeout(t);
}, [q]);
const rows: AdminUser[] = load.state === "ok" ? load.data : [];
const open = (id: string) => setSelected((cur) => (cur === id ? null : id));
const onRowKey = (e: KeyboardEvent, id: string) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
open(id);
}
};
return (
<div className="stack">
<form className="frow" role="search" onSubmit={(e) => e.preventDefault()}>
<label className="f">
Search users
<input className="in" type="search" placeholder="Name, account or employee ID" value={q} onChange={(e) => setQ(e.target.value)} />
</label>
</form>
<Problem load={load} />
{load.state === "ok" ? (
<div className="tw">
<table>
<thead>
<tr>
<th>Name</th>
<th>Account</th>
<th>Employee ID</th>
<th>Roles</th>
<th>PIN</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{rows.map((u) => (
<tr
key={u.id}
className={selected === u.id ? "rowlink on" : "rowlink"}
tabIndex={0}
aria-selected={selected === u.id}
onClick={() => open(u.id)}
onKeyDown={(e) => onRowKey(e, u.id)}
>
<td>{u.display_name}</td>
<td className="mono small">{u.sam_account}</td>
<td className="mono">
{u.emp_no ?? ""} {u.flags.emp_missing ? <span className="pill st-bad">Missing</span> : null}
{u.flags.emp_duplicate ? <span className="pill st-bad">Duplicate</span> : null}
</td>
<td>
<RoleChips roles={u.roles} label={`Roles of ${u.display_name}`} />
</td>
<td>
<PinPill pin={u.pin} />
</td>
<td>
{u.flags.disabled ? (
<span className="pill st-bad">Disabled in AD</span>
) : u.active ? (
<span className="pill st-ok">Active</span>
) : (
<span className="pill st-filed">Inactive</span>
)}
</td>
</tr>
))}
{rows.length === 0 ? (
<tr>
<td colSpan={6} className="muted">
No users match.
</td>
</tr>
) : null}
</tbody>
</table>
</div>
) : null}
{selected ? <UserDetail key={selected} id={selected} onChanged={reload} onClose={() => setSelected(null)} /> : null}
</div>
);
}
/* ---------- Look up AD account ---------- */
function LookupTab() {
const [login, setLogin] = useState("");
const [busy, setBusy] = useState(false);
const [result, setResult] = useState<AdLookup | null>(null);
const [error, setError] = useState<string | null>(null);
const submit = async (e: FormEvent) => {
e.preventDefault();
if (!login.trim()) return;
setBusy(true);
setError(null);
try {
setResult(await adminApi.lookup(login.trim()));
} catch (err) {
setResult(null);
setError(errorText(err));
} finally {
setBusy(false);
}
};
const u = result?.user ?? null;
return (
<div className="stack">
<div className="small muted">Reads one account live from AD. Nothing is saved.</div>
<form className="frow" onSubmit={submit} noValidate>
<label className="f">
Account name
<input className="in" autoCapitalize="none" spellCheck={false} value={login} onChange={(e) => setLogin(e.target.value)} />
</label>
<div className="end">
<button className="btn pri" type="submit" disabled={busy}>
Look up
</button>
</div>
</form>
{error ? (
<div className="callout warn" role="alert">
{error}
</div>
) : null}
{result && !result.found ? <div className="callout">No account by that name in AD.</div> : null}
{result && result.found ? (
<div className="panel stack">
<div className="sec-h">
<h3>{u?.display_name ?? login}</h3>
<span className="mono small muted">{u?.sam_account ?? ""}</span>
{result.disabled ? <span className="pill st-bad">Disabled in AD</span> : <span className="pill st-ok">Enabled</span>}
</div>
<dl className="kv">
<div>
<dt>UPN</dt>
<dd>{u?.upn ?? "-"}</dd>
</div>
<div>
<dt>Email</dt>
<dd>{u?.email ?? "-"}</dd>
</div>
<div>
<dt>Employee ID</dt>
<dd className="mono">{u?.emp_no ?? <span className="muted">Missing</span>}</dd>
</div>
<div>
<dt>Groups</dt>
<dd>
<GroupChips groups={result.groups ?? []} />
</dd>
</div>
<div>
<dt>Roles</dt>
<dd>
<RoleChips roles={result.roles ?? []} label="Roles" />
</dd>
</div>
</dl>
</div>
) : null}
</div>
);
}
/* ---------- Devices ---------- */
function DevicesTab() {
const [load, reload] = useLoad((s) => adminApi.devices(s));
const [confirm, setConfirm] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const revoke = async (id: string) => {
setError(null);
try {
await adminApi.revokeDevice(id);
setConfirm(null);
reload();
} catch (err) {
setError(errorText(err));
}
};
const rows = load.state === "ok" ? load.data : [];
return (
<div className="stack">
<div className="small muted">Revoking a device ends every PIN session on it.</div>
{error ? (
<div className="callout warn" role="alert">
{error}
</div>
) : null}
<Problem load={load} />
{load.state === "ok" ? (
<div className="tw">
<table>
<thead>
<tr>
<th>Name</th>
<th>Registered by</th>
<th>Registered</th>
<th>Last seen</th>
<th className="num">Sessions</th>
<th>Status</th>
<th />
</tr>
</thead>
<tbody>
{rows.map((d) => (
<tr key={d.id}>
<td>{d.name}</td>
<td>{d.registered_by?.display_name ?? "-"}</td>
<td className="small mono">{fmtTime(d.registered_at)}</td>
<td className="small mono">{fmtTime(d.last_seen_at)}</td>
<td className="num">{d.active_sessions}</td>
<td>{d.revoked_at ? <span className="pill st-filed">Revoked</span> : <span className="pill st-ok">Active</span>}</td>
<td className="nowrap">
{d.revoked_at ? null : confirm === d.id ? (
<>
<button className="btn sm rejb" type="button" onClick={() => void revoke(d.id)}>
Confirm revoke
</button>{" "}
<button className="btn sm ghost" type="button" onClick={() => setConfirm(null)}>
Cancel
</button>
</>
) : (
<button className="btn sm ghost" type="button" onClick={() => setConfirm(d.id)}>
Revoke
</button>
)}
</td>
</tr>
))}
{rows.length === 0 ? (
<tr>
<td colSpan={7} className="muted">
No devices registered.
</td>
</tr>
) : null}
</tbody>
</table>
</div>
) : null}
</div>
);
}
/* ---------- Group map ---------- */
function InAdPill({ inAd }: { inAd: boolean | null }) {
if (inAd === true) return <span className="pill st-ok">In AD</span>;
if (inAd === false) return <span className="pill st-bad">Missing in AD</span>;
return <span className="pill st-wait">AD unreachable</span>;
}
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]> = [
[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 (
<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>
{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}
{names(r.groups_missing).length ? (
<div className="callout warn flush">
Missing groups: <span className="mono">{names(r.groups_missing).join(", ")}</span>
</div>
) : null}
{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}
{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 [requested, setRequested] = useState<RefreshRequested | null>(null);
const [error, setError] = useState<string | null>(null);
const refresh = async () => {
setBusy(true);
setError(null);
try {
setRequested(await admin9Api.refreshAd());
reload();
reloadReport();
} catch (err) {
setError(errorText(err));
} finally {
setBusy(false);
}
};
const map = load.state === "ok" ? load.data : null;
const missing = map ? map.groups.filter((g) => g.in_ad === false).length : 0;
const label = (g: GroupRow) => roleLabel(roleFromKind(g.role, g.dept_code));
return (
<div className="stack">
<div className="sec-h">
<span className="small muted">
The groups Packet Station reads, from the departments table.
{map?.checked_at ? ` Checked ${fmtTime(map.checked_at)}.` : ""}
</span>
<span className="grow" />
<button className="btn sm pri" type="button" disabled={busy} onClick={() => void refresh()}>
{busy ? "Asking the worker" : "Refresh from AD now"}
</button>
</div>
{error ? (
<div className="callout warn" role="alert">
{error}
</div>
) : 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 ? (
<div className="callout warn">
{missing === 1 ? "1 group is" : `${missing} groups are`} missing in AD. Ask IT to create {missing === 1 ? "it" : "them"}.
</div>
) : null}
{map ? (
<div className="tw">
<table>
<thead>
<tr>
<th>Group</th>
<th>Role</th>
<th>In AD</th>
</tr>
</thead>
<tbody>
{map.groups.map((g) => (
<tr key={g.name}>
<td className="mono small">{g.name}</td>
<td>
<span className={roleChipClass(roleFromKind(g.role, g.dept_code))}>{label(g)}</span>
</td>
<td>
<InAdPill inAd={g.in_ad} />
</td>
</tr>
))}
</tbody>
</table>
</div>
) : null}
{map ? (
<section aria-labelledby="extra-h">
<h3 id="extra-h">Other groups in the OU</h3>
{map.extra_in_ou.length ? (
<>
<div className="small muted">Packet Station does not use these.</div>
<ul className="chips plain">
{map.extra_in_ou.map((g) => (
<li key={g} className="chip static mono">
{g}
</li>
))}
</ul>
</>
) : (
<div className="small muted">None.</div>
)}
</section>
) : null}
</div>
);
}
/* ---------- Sign-in attempts ---------- */
function AttemptsTab() {
const [load, reload] = useLoad((s) => adminApi.signinAttempts(100, s));
const rows = load.state === "ok" ? load.data : [];
return (
<div className="stack">
<div className="sec-h">
<span className="small muted">Recent failed attempts. Newest first.</span>
<span className="grow" />
<button className="btn sm ghost" type="button" onClick={reload}>
Refresh
</button>
</div>
<Problem load={load} />
{load.state === "ok" ? (
<div className="tw">
<table>
<thead>
<tr>
<th>When</th>
<th>Account</th>
<th>Kind</th>
<th>Reason</th>
<th>Address</th>
</tr>
</thead>
<tbody>
{rows.map((a, i) => (
<tr key={a.id ?? `${a.at}-${i}`}>
<td className="small mono">{fmtTime(a.at)}</td>
<td className="mono small">{a.account}</td>
<td>{a.kind}</td>
<td>{a.reason ? a.reason.replace(/_/g, " ") : "-"}</td>
<td className="mono small">{a.ip ?? "-"}</td>
</tr>
))}
{rows.length === 0 ? (
<tr>
<td colSpan={5} className="muted">
No failed attempts.
</td>
</tr>
) : null}
</tbody>
</table>
</div>
) : null}
</div>
);
}
/** /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");
const head = (
<div className="phead">
<div>
<div className="eyebrow">Admin</div>
<h1>Users and access</h1>
</div>
</div>
);
if (me?.session.kind !== "password") {
return (
<>
{head}
<div className="callout warn">Admin needs a password sign-in. Sign out and sign in with your domain password.</div>
</>
);
}
return (
<>
{head}
{/* 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}
type="button"
role="tab"
id={`admtab-${t.id}`}
aria-selected={tab === t.id}
aria-controls={`admpanel-${t.id}`}
onClick={() => setTab(t.id)}
>
{t.label}
</button>
))}
</div>
<div role="tabpanel" id={`admpanel-${tab}`} aria-labelledby={`admtab-${tab}`}>
{tab === "users" ? <UsersTab /> : null}
{tab === "lookup" ? <LookupTab /> : null}
{tab === "devices" ? <DevicesTab /> : null}
{tab === "groups" ? <GroupsTab /> : null}
{tab === "attempts" ? <AttemptsTab /> : null}
{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>
</>
);
}