Files
PacketStation/frontend/src/screens/admin/DepartmentsTab.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

343 lines
12 KiB
TypeScript

// 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>
);
}