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,342 @@
|
||||
// Admin: departments, their stations and the AD groups each one needs (phase 9).
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { admin9Api, type AdminDepartment, type DeptAdded, type DeptGroup } from "../../api/admin";
|
||||
import { errorText } from "../../lib/format";
|
||||
import { useLoad } from "../../lib/useLoad";
|
||||
import { Notice, Problem, type Msg } from "./common";
|
||||
|
||||
const RX_CODE = /^[0-9A-Z]{2,4}$/;
|
||||
const RX_SLUG = /^[A-Za-z][A-Za-z0-9]{1,30}$/;
|
||||
const SHOP_LEVELS = ["Worker", "Lead", "Supervisor"] as const;
|
||||
|
||||
function OuPill({ g }: { g: DeptGroup }) {
|
||||
if (g.in_ou === true) return <span className="pill st-ok">In AD</span>;
|
||||
if (g.in_ou === false) return <span className="pill st-bad">Missing in AD</span>;
|
||||
return <span className="pill st-wait">Not checked</span>;
|
||||
}
|
||||
|
||||
type StationDraft = { id?: number; name: string; active: boolean };
|
||||
|
||||
function DeptEditor({ d, onSaved, onClose }: { d: AdminDepartment; onSaved: () => void; onClose: () => void }) {
|
||||
const [name, setName] = useState(d.name);
|
||||
const [active, setActive] = useState(d.active);
|
||||
const [stations, setStations] = useState<StationDraft[]>(
|
||||
[...d.stations].sort((a, b) => a.sort - b.sort).map((s) => ({ id: s.id, name: s.name, active: s.active })),
|
||||
);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [msg, setMsg] = useState<Msg>(null);
|
||||
|
||||
const setStation = (i: number, patch: Partial<StationDraft>) =>
|
||||
setStations((cur) => cur.map((s, j) => (j === i ? { ...s, ...patch } : s)));
|
||||
|
||||
const save = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
const names = stations.map((s) => s.name.trim());
|
||||
if (!name.trim()) {
|
||||
setMsg({ ok: false, text: "Give the department a name." });
|
||||
return;
|
||||
}
|
||||
if (names.some((n) => !n)) {
|
||||
setMsg({ ok: false, text: "Every station needs a name." });
|
||||
return;
|
||||
}
|
||||
if (new Set(names.map((n) => n.toLowerCase())).size !== names.length) {
|
||||
setMsg({ ok: false, text: "Two stations have the same name." });
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setMsg(null);
|
||||
try {
|
||||
await admin9Api.patchDepartment(d.code, {
|
||||
name: name.trim(),
|
||||
active,
|
||||
stations: stations.map((s, i) => ({ ...(s.id !== undefined ? { id: s.id } : {}), name: s.name.trim(), sort: i + 1, active: s.active })),
|
||||
});
|
||||
setMsg({ ok: true, text: "Department saved." });
|
||||
onSaved();
|
||||
} catch (err) {
|
||||
setMsg({ ok: false, text: errorText(err) });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form className="panel stack" onSubmit={save} noValidate aria-labelledby="dept-edit-h">
|
||||
<div className="sec-h">
|
||||
<h3 id="dept-edit-h">
|
||||
Edit {d.code} {d.name}
|
||||
</h3>
|
||||
<span className="grow" />
|
||||
<button className="btn sm ghost" type="button" onClick={onClose}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
<div className="frow">
|
||||
<label className="f">
|
||||
Department name
|
||||
<input className="in" value={name} maxLength={80} onChange={(e) => setName(e.target.value)} />
|
||||
</label>
|
||||
<label className="ckl">
|
||||
<input type="checkbox" checked={active} onChange={(e) => setActive(e.target.checked)} />
|
||||
Department active
|
||||
</label>
|
||||
</div>
|
||||
<h3>Stations</h3>
|
||||
<div className="small muted">Stations are never deleted, because station limits point at them. Clear Active to hide one.</div>
|
||||
{stations.map((s, i) => (
|
||||
<div className="frow" key={s.id ?? `new-${i}`}>
|
||||
<label className="f">
|
||||
{`Station ${i + 1} name`}
|
||||
<input className="in" value={s.name} maxLength={60} onChange={(e) => setStation(i, { name: e.target.value })} />
|
||||
</label>
|
||||
<label className="ckl">
|
||||
<input type="checkbox" checked={s.active} aria-label={`Station ${i + 1} active`} onChange={(e) => setStation(i, { active: e.target.checked })} />
|
||||
Active
|
||||
</label>
|
||||
<div className="end">
|
||||
<button
|
||||
className="btn sm ghost"
|
||||
type="button"
|
||||
aria-label={`Move station ${i + 1} up`}
|
||||
disabled={i === 0}
|
||||
onClick={() => setStations((cur) => swap(cur, i, i - 1))}
|
||||
>
|
||||
Up
|
||||
</button>{" "}
|
||||
<button
|
||||
className="btn sm ghost"
|
||||
type="button"
|
||||
aria-label={`Move station ${i + 1} down`}
|
||||
disabled={i === stations.length - 1}
|
||||
onClick={() => setStations((cur) => swap(cur, i, i + 1))}
|
||||
>
|
||||
Down
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div>
|
||||
<button className="btn sm" type="button" onClick={() => setStations((cur) => [...cur, { name: "", active: true }])}>
|
||||
Add station
|
||||
</button>
|
||||
</div>
|
||||
<Notice msg={msg} />
|
||||
<div>
|
||||
<button className="btn pri" type="submit" disabled={busy}>
|
||||
Save department
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function swap<T>(list: T[], a: number, b: number): T[] {
|
||||
const out = [...list];
|
||||
const x = out[a];
|
||||
const y = out[b];
|
||||
if (x === undefined || y === undefined) return list;
|
||||
out[a] = y;
|
||||
out[b] = x;
|
||||
return out;
|
||||
}
|
||||
|
||||
function AddDepartment({ onAdded }: { onAdded: (r: DeptAdded) => void }) {
|
||||
const [code, setCode] = useState("");
|
||||
const [slug, setSlug] = useState("");
|
||||
const [name, setName] = useState("");
|
||||
const [kind, setKind] = useState<"shop" | "support">("shop");
|
||||
const [weld, setWeld] = useState(false);
|
||||
const [sup, setSup] = useState(true);
|
||||
const [stations, setStations] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [msg, setMsg] = useState<Msg>(null);
|
||||
|
||||
const preview = kind === "shop" && RX_CODE.test(code) && RX_SLUG.test(slug) ? SHOP_LEVELS.map((l) => `PS-${code}-${slug}-${l}`) : [];
|
||||
|
||||
const submit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!RX_CODE.test(code)) {
|
||||
setMsg({ ok: false, text: "The code is 2 to 4 capital letters or digits, like 15." });
|
||||
return;
|
||||
}
|
||||
if (!RX_SLUG.test(slug)) {
|
||||
setMsg({ ok: false, text: "The short name starts with a letter and has only letters and digits, like Paint." });
|
||||
return;
|
||||
}
|
||||
if (!name.trim()) {
|
||||
setMsg({ ok: false, text: "Give the department a name." });
|
||||
return;
|
||||
}
|
||||
const list = stations
|
||||
.split(/\r?\n/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
setBusy(true);
|
||||
setMsg(null);
|
||||
try {
|
||||
const r = await admin9Api.addDepartment({ code, slug, name: name.trim(), kind, weld, supervisor_group: sup, stations: list });
|
||||
setCode("");
|
||||
setSlug("");
|
||||
setName("");
|
||||
setStations("");
|
||||
onAdded(r);
|
||||
} catch (err) {
|
||||
setMsg({ ok: false, text: errorText(err) });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form className="panel stack" onSubmit={submit} noValidate aria-labelledby="dept-add-h">
|
||||
<h3 id="dept-add-h">Add department</h3>
|
||||
<div className="frow">
|
||||
<label className="f">
|
||||
Code
|
||||
<input className="in mono" value={code} maxLength={4} onChange={(e) => setCode(e.target.value.toUpperCase())} />
|
||||
</label>
|
||||
<label className="f">
|
||||
Short name
|
||||
<input className="in" value={slug} maxLength={31} onChange={(e) => setSlug(e.target.value)} />
|
||||
</label>
|
||||
<label className="f">
|
||||
Name
|
||||
<input className="in" value={name} maxLength={80} onChange={(e) => setName(e.target.value)} />
|
||||
</label>
|
||||
<label className="f">
|
||||
Kind
|
||||
<select className="in" aria-label="Kind" value={kind} onChange={(e) => setKind(e.target.value === "support" ? "support" : "shop")}>
|
||||
<option value="shop">Shop</option>
|
||||
<option value="support">Support</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div className="frow">
|
||||
<label className="ckl">
|
||||
<input type="checkbox" checked={weld} onChange={(e) => setWeld(e.target.checked)} />
|
||||
Weld department
|
||||
</label>
|
||||
<label className="ckl">
|
||||
<input type="checkbox" checked={sup} onChange={(e) => setSup(e.target.checked)} />
|
||||
Has a Supervisor group
|
||||
</label>
|
||||
</div>
|
||||
<label className="f">
|
||||
Stations, one per line
|
||||
<textarea className="in" aria-label="Stations, one per line" value={stations} onChange={(e) => setStations(e.target.value)} />
|
||||
</label>
|
||||
{preview.length ? (
|
||||
<div className="small">
|
||||
IT will need to create these groups: <span className="mono">{preview.join(", ")}</span>
|
||||
</div>
|
||||
) : null}
|
||||
<Notice msg={msg} />
|
||||
<div>
|
||||
<button className="btn pri" type="submit" disabled={busy}>
|
||||
Add department
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export function DepartmentsTab() {
|
||||
const [load, reload] = useLoad((s) => admin9Api.departments(s));
|
||||
const [editing, setEditing] = useState<string | null>(null);
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [added, setAdded] = useState<DeptAdded | null>(null);
|
||||
|
||||
const rows = load.state === "ok" ? [...load.data].sort((a, b) => a.sort - b.sort) : [];
|
||||
const current = rows.find((d) => d.code === editing) ?? null;
|
||||
|
||||
return (
|
||||
<div className="stack">
|
||||
<div className="sec-h">
|
||||
<span className="small muted">Each department maps to AD groups by its code and short name. Roles come from those groups.</span>
|
||||
<span className="grow" />
|
||||
<button className="btn sm pri" type="button" aria-expanded={adding} onClick={() => setAdding((a) => !a)}>
|
||||
{adding ? "Close the new department form" : "New department"}
|
||||
</button>
|
||||
</div>
|
||||
{added ? (
|
||||
<div className="callout" role="status" aria-label="Groups to create">
|
||||
<b>
|
||||
Added {added.department.code} {added.department.name}.
|
||||
</b>{" "}
|
||||
{added.groups_to_create.length ? (
|
||||
<>
|
||||
Ask IT to create these groups in the PacketStation OU:
|
||||
<ul className="chips plain">
|
||||
{added.groups_to_create.map((g) => (
|
||||
<li key={g} className="chip static mono">
|
||||
{g}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
) : (
|
||||
"No new groups are needed."
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
{adding ? (
|
||||
<AddDepartment
|
||||
onAdded={(r) => {
|
||||
setAdded(r);
|
||||
setAdding(false);
|
||||
reload();
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<Problem load={load} />
|
||||
{load.state === "ok" ? (
|
||||
<div className="tw">
|
||||
<table aria-label="Departments">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Code</th>
|
||||
<th>Name</th>
|
||||
<th>Kind</th>
|
||||
<th>Status</th>
|
||||
<th>Groups</th>
|
||||
<th>Stations</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((d) => (
|
||||
<tr key={d.code}>
|
||||
<td className="mono">{d.code}</td>
|
||||
<td>
|
||||
{d.name} {d.weld ? <span className="pill st-ready">Weld</span> : null}
|
||||
</td>
|
||||
<td>{d.kind === "shop" ? "Shop" : "Support"}</td>
|
||||
<td>{d.active ? <span className="pill st-ok">Active</span> : <span className="pill st-filed">Inactive</span>}</td>
|
||||
<td>
|
||||
<ul className="plain stack tight" style={{ listStyle: "none", margin: 0, padding: 0 }}>
|
||||
{d.groups.map((g) => (
|
||||
<li key={g.name} className="small">
|
||||
<span className="mono">{g.name}</span> <OuPill g={g} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</td>
|
||||
<td className="small">
|
||||
{d.stations.filter((s) => s.active).map((s) => s.name).join(", ") || <span className="muted">None</span>}
|
||||
</td>
|
||||
<td>
|
||||
<button className="btn sm ghost" type="button" aria-label={`Edit department ${d.code}`} onClick={() => setEditing(d.code)}>
|
||||
Edit
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : null}
|
||||
{current ? <DeptEditor key={current.code} d={current} onSaved={reload} onClose={() => setEditing(null)} /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user