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
+409
View File
@@ -0,0 +1,409 @@
// Phase 9 Admin: departments, profiles, checklists, security, access log, presence, stamp check, directory flags,
// the AD reconcile, and notifications. Field names follow the phase 9 design contract exactly.
import type { AdminUser } from "../auth/types";
import { api } from "./client";
import { qs } from "./query";
const enc = encodeURIComponent;
/* ---------- Departments ---------- */
export interface DeptGroup {
name: string;
role: string;
/** null when AD could not be read. */
in_ou: boolean | null;
}
export interface DeptStation {
id: number;
name: string;
sort: number;
active: boolean;
}
export interface AdminDepartment {
code: string;
slug: string;
name: string;
kind: "shop" | "support";
sort: number;
active: boolean;
weld: boolean;
supervisor_group: boolean;
created_at: string | null;
groups: DeptGroup[];
stations: DeptStation[];
}
export interface DeptPatch {
name?: string;
active?: boolean;
stations?: Array<{ id?: number; name: string; sort: number; active: boolean }>;
}
export interface DeptNew {
code: string;
slug: string;
name: string;
kind: "shop" | "support";
weld: boolean;
supervisor_group: boolean;
stations: string[];
}
export interface DeptAdded {
department: AdminDepartment;
groups_to_create: string[];
}
/* ---------- Profiles ---------- */
export interface DirectoryFlags {
emp_missing?: boolean;
emp_duplicate?: boolean;
gone?: boolean;
disabled?: boolean;
}
/** A row of GET /api/admin/users with the phase 9 profile fields. */
export interface ProfileUser extends AdminUser {
welder_stamp?: string | null;
profile_active?: boolean;
station_limits?: Record<string, number[]>;
directory_flags?: DirectoryFlags | null;
}
export interface ProfilePatch {
welder_stamp?: string | null;
profile_active?: boolean;
station_limits?: Record<string, number[]>;
}
export interface ImportRow {
line: number;
employee_id: string;
user_id: string | null;
display_name: string | null;
changes: Record<string, { from: unknown; to: unknown }>;
errors: string[];
}
export interface ImportAnswer {
rows: ImportRow[];
ok: number;
errors: number;
applied: boolean;
}
/* ---------- Checklists ---------- */
export interface ChecklistItem {
group: string;
key: string;
text: string;
}
export interface ChecklistLists {
weld: ChecklistItem[];
plain: ChecklistItem[];
}
export interface ChecklistsAnswer {
value: ChecklistLists;
defaults: ChecklistLists;
version: number;
}
/* ---------- Security ---------- */
export interface SecuritySettingsAnswer {
values: Record<string, number>;
defaults: Record<string, number>;
bounds: Record<string, [number, number]>;
version: number;
}
export type AlertKind =
| "lockout"
| "pin_failures"
| "denied"
| "stepup_wrong_pin"
| "admin_change"
| "it_edit"
| "integrity"
| "signin_burst";
export interface SecurityAlert {
at: string;
kind: AlertKind | string;
user_id: string | null;
display_name: string | null;
account: string | null;
ip: string | null;
detail: unknown;
}
export interface IntegrityFinding {
kind: string;
subject: string;
detail: unknown;
at: string;
}
export interface IntegrityAnswer {
run: { id: number; started_at: string | null; finished_at: string | null; status: string; counts: Record<string, unknown> | null } | null;
findings: IntegrityFinding[];
}
/* ---------- Access log ---------- */
export interface AccessLogFilters {
since?: string;
until?: string;
user_id?: string;
q?: string;
kind?: string;
ok?: "true" | "false" | "";
ip?: string;
limit?: number;
before?: string;
}
export interface AccessLogRow {
id: string | number;
at: string;
source: "attempt" | "audit";
kind: string;
ok: boolean;
account: string | null;
user_id: string | null;
display_name: string | null;
ip: string | null;
detail: unknown;
}
export interface AccessLogAnswer {
rows: AccessLogRow[];
next_before: string | null;
}
/* ---------- Presence ---------- */
export interface PresenceSession {
session_id: string;
user_id: string;
display_name: string;
kind: "password" | "pin" | string;
device_name: string | null;
ip: string | null;
created_at: string;
last_seen_at: string | null;
locked: boolean;
screen: string | null;
packet_id: number | null;
}
export interface PresenceAnswer {
at: string;
sessions: PresenceSession[];
}
/* ---------- Stamp check ---------- */
export interface StampResult {
found: boolean;
valid: boolean;
reason: string | null;
print: {
id: number;
at: string;
by_name: string | null;
kind: string;
what: string | null;
pages: number | null;
packet_id: number | null;
packet_label: string | null;
} | null;
}
/* ---------- Directory flags and the reconcile ---------- */
export interface FlagUser {
user_id: string;
sam_account: string;
display_name: string;
}
export interface DirectoryFlagsAnswer {
checked_at: string | null;
missing: FlagUser[];
duplicate: Array<{ emp_no: string; users: FlagUser[] }>;
}
export interface RefreshRequested {
requested: boolean;
pending: number;
job: string;
}
export interface ReconcileReport {
groups_missing: string[] | number;
groups_extra: string[] | number;
users_seen: number;
created: number;
updated: number;
deactivated: number;
sessions_ended: number;
emp_missing: string[] | number;
emp_duplicate: string[] | number;
}
export interface ReconcileAnswer {
run: { id: number; started_at: string | null; finished_at: string | null; status: string } | null;
report: Partial<ReconcileReport> | null;
}
/* ---------- Notifications ---------- */
export type RecipientKind = "group" | "user" | "mailbox" | "dept_supervisor";
export interface Recipient {
id?: number;
kind: RecipientKind;
group_dn?: string | null;
group_name?: string | null;
user_id?: string | null;
display_name?: string | null;
address?: string | null;
}
export interface NotifyRule {
event_type: string;
label: string;
description: string;
security: boolean;
dept_scoped: boolean;
param: { name: "hours" | "days" | string; value: number; default: number } | null;
enabled: boolean;
delivery: "immediate" | "digest";
digest_time: string;
recipients: Recipient[];
exclusions: Array<{ user_id: string; display_name: string | null; self: boolean }>;
version: number;
last_sent: { at: string; result: string } | null;
}
export interface NotifyAnswer {
dry_run: boolean;
smtp: { host: string | null; port: number | null; from: string | null; starttls: boolean };
subject_prefix: string;
rules: NotifyRule[];
}
export interface RulePut {
enabled: boolean;
delivery: "immediate" | "digest";
digest_time: string;
param: number | null;
recipients: Array<{ kind: RecipientKind; group_dn?: string; group_name?: string; user_id?: string; address?: string }>;
exclusions: string[];
version: number;
}
export interface AdGroupHit {
dn: string;
name: string;
description: string | null;
}
export interface TestSendAnswer {
result: string;
subject: string;
recipients: Array<{ address: string; user_id?: string | null; why?: string }> | string[];
error: string | null;
sent_id: number | null;
}
export interface SentRow {
id: number;
at: string;
event_type: string;
digest: boolean;
test: boolean;
subject: string;
recipients: Array<{ address: string; user_id?: string | null; why?: string }>;
result: string;
error: string | null;
}
export interface SentAnswer {
rows: SentRow[];
/** The id to page from. */
next_before: string | number | null;
}
export interface MyNotification {
event_type: string;
label: string;
security: boolean;
delivery: "immediate" | "digest";
reaches: boolean;
why: string[];
opted_out: boolean;
can_opt_out: boolean;
}
/* ---------- Calls ---------- */
const A = "/api/admin";
export const admin9Api = {
departments: (signal?: AbortSignal) => api.get<AdminDepartment[]>(`${A}/departments`, signal),
patchDepartment: (code: string, patch: DeptPatch) => api.patch<AdminDepartment>(`${A}/departments/${enc(code)}`, patch),
addDepartment: (d: DeptNew) => api.post<DeptAdded>(`${A}/departments`, d),
users: (q: string, signal?: AbortSignal) => api.get<ProfileUser[]>(`${A}/users${qs({ q })}`, signal),
patchProfile: (id: string, patch: ProfilePatch) => api.patch<ProfileUser>(`${A}/users/${enc(id)}/profile`, patch),
importProfiles: (csv: string, apply: boolean) => api.post<ImportAnswer>(`${A}/users/import`, { csv, apply }),
checklists: (signal?: AbortSignal) => api.get<ChecklistsAnswer>(`${A}/checklists`, signal),
saveChecklists: (value: ChecklistLists, version: number) => api.put<ChecklistsAnswer>(`${A}/checklists`, { value, version }),
securitySettings: (signal?: AbortSignal) => api.get<SecuritySettingsAnswer>(`${A}/security/settings`, signal),
saveSecuritySettings: (values: Record<string, number>, version: number) =>
api.put<SecuritySettingsAnswer>(`${A}/security/settings`, { values, version }),
securityAlerts: (hours: number, signal?: AbortSignal) => api.get<SecurityAlert[]>(`${A}/security/alerts${qs({ hours })}`, signal),
integrity: (signal?: AbortSignal) => api.get<IntegrityAnswer>(`${A}/security/integrity`, signal),
accessLog: (f: AccessLogFilters, signal?: AbortSignal) =>
api.get<AccessLogAnswer>(`${A}/access-log${qs({ ...f })}`, signal),
accessLogCsvUrl: (f: AccessLogFilters) => `${A}/access-log.csv${qs({ ...f, before: undefined, limit: undefined })}`,
presence: (signal?: AbortSignal) => api.get<PresenceAnswer>(`${A}/presence`, signal),
stampVerify: (text: string) => api.post<StampResult>(`${A}/stamp-verify`, { text }),
stampVerifyPdf: (file: File) => {
const fd = new FormData();
fd.append("file", file);
return api.post<{ stamps: StampResult[] }>(`${A}/stamp-verify/pdf`, fd);
},
directoryFlags: (signal?: AbortSignal) => api.get<DirectoryFlagsAnswer>(`${A}/directory-flags`, signal),
refreshAd: () => api.post<RefreshRequested>(`${A}/ad/refresh`, {}),
reconcile: (signal?: AbortSignal) => api.get<ReconcileAnswer>(`${A}/ad/reconcile`, signal),
notifications: (signal?: AbortSignal) => api.get<NotifyAnswer>(`${A}/notifications`, signal),
saveRule: (eventType: string, body: RulePut) => api.put<NotifyRule>(`${A}/notifications/${enc(eventType)}`, body),
searchGroups: (q: string, signal?: AbortSignal) => api.get<AdGroupHit[]>(`${A}/notifications/groups${qs({ q })}`, signal),
testSend: (eventType: string, to?: string) =>
api.post<TestSendAnswer>(`${A}/notifications/${enc(eventType)}/test`, to ? { to } : {}),
sent: (f: { event_type?: string | undefined; limit?: number; before?: string | number | undefined }, signal?: AbortSignal) =>
api.get<SentAnswer>(`${A}/notifications/sent${qs(f)}`, signal),
};
export const accountNotifyApi = {
list: (signal?: AbortSignal) => api.get<MyNotification[]>("/api/account/notifications", signal),
setOptOut: (eventType: string, optOut: boolean) =>
api.put<MyNotification | null>(`/api/account/notifications/${enc(eventType)}`, { opt_out: optOut }),
};
-2
View File
@@ -4,7 +4,6 @@ import type {
AccessLogRow,
AccountInfo,
AdLookup,
AdRefreshResult,
AdminDevice,
AdminUser,
AdminUserDetail,
@@ -55,7 +54,6 @@ export const adminApi = {
devices: (signal?: AbortSignal) => api.get<AdminDevice[]>("/api/admin/devices", signal),
revokeDevice: (id: string) => api.del<null>(`/api/admin/devices/${enc(id)}`),
groups: (signal?: AbortSignal) => api.get<GroupMap>("/api/admin/groups", signal),
refresh: () => api.post<AdRefreshResult>("/api/admin/ad/refresh"),
signinAttempts: (limit = 100, signal?: AbortSignal) =>
api.get<SigninAttempt[]>(`/api/admin/signin-attempts?limit=${limit}`, signal),
};
-12
View File
@@ -149,18 +149,6 @@ export interface GroupMap {
extra_in_ou: string[];
}
/** POST /api/admin/ad/refresh. */
export interface AdRefreshResult {
groups_checked: number;
groups_missing: string[];
users_seen: number;
created: number;
updated: number;
deactivated: number;
emp_missing: string[];
emp_duplicate: string[];
}
/**
* One row of GET /api/admin/signin-attempts. The contract does not list the fields.
* These are the signin_attempts columns.
@@ -1,4 +1,4 @@
import { fireEvent, screen } from "@testing-library/react";
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { pinProblem } from "../../lib/pin";
import { makeMe, renderApp } from "../../test/auth";
@@ -12,6 +12,39 @@ const ACCOUNT = {
ad_synced_at: "2026-10-16T08:00:00Z",
};
const NOTIFY = [
{
event_type: "security_alert",
label: "Security alert",
security: true,
delivery: "immediate",
reaches: true,
why: ["group PS-Admins"],
opted_out: false,
can_opt_out: false,
},
{
event_type: "co_waiting",
label: "Change order waiting",
security: false,
delivery: "digest",
reaches: true,
why: ["Supervisor group of 03", "user"],
opted_out: false,
can_opt_out: true,
},
{
event_type: "version_deployed",
label: "Version deployed",
security: false,
delivery: "immediate",
reaches: false,
why: [],
opted_out: false,
can_opt_out: true,
},
];
function routes(me = makeMe()) {
return {
"/api/auth/me": { body: me },
@@ -33,6 +66,7 @@ function routes(me = makeMe()) {
"/api/account/access-log": {
body: [{ at: "2026-10-16T08:00:00Z", action: "auth.signin", detail: { kind: "password" }, ip: "10.0.0.5", ok: true }],
},
"/api/account/notifications": { body: NOTIFY },
"/api/auth/context": { body: { device: null, stack: "test", insecure_ldap: false, pin_digits: { min: 6, max: 12 } } },
};
}
@@ -107,4 +141,42 @@ describe("AccountPage", () => {
expect(await screen.findByText("Weld Parts Stall 3")).toBeInTheDocument();
expect(callsTo(fetch, "POST", "/api/account/device")).toEqual([{ name: "Weld Parts Stall 3" }]);
});
it("shows what mail reaches me and why, and opts out of one", async () => {
let opted = false;
const fetch = mockFetch({
...routes(),
"/api/account/notifications": () => ({ body: NOTIFY.map((n) => (n.event_type === "co_waiting" ? { ...n, opted_out: opted } : n)) }),
"PUT /api/account/notifications/co_waiting": (req) => {
opted = (req.body as { opt_out: boolean }).opt_out;
return { body: null, status: 204 };
},
});
renderApp("/account");
const table = await screen.findByRole("table", { name: "Notifications" });
const sec = (await within(table).findByText("Security alert")).closest("tr") as HTMLElement;
expect(within(sec).getByText("Security events cannot be turned off.")).toBeInTheDocument();
expect(within(sec).queryByRole("checkbox")).not.toBeInTheDocument();
expect(within(sec).getByText("group PS-Admins")).toBeInTheDocument();
const co = within(table).getByText("Change order waiting").closest("tr") as HTMLElement;
expect(within(co).getByText("Supervisor group of 03, user")).toBeInTheDocument();
expect(within(co).getByText("Daily digest")).toBeInTheDocument();
const ver = within(table).getByText("Version deployed").closest("tr") as HTMLElement;
expect(within(ver).getByText("No")).toBeInTheDocument();
expect(within(ver).getByText("Not on the list")).toBeInTheDocument();
fireEvent.click(within(co).getByLabelText("Opt out of Change order waiting"));
expect(await screen.findByText("Change order waiting: you will not get this mail.")).toBeInTheDocument();
expect(callsTo(fetch, "PUT", "/api/account/notifications/co_waiting")).toEqual([{ opt_out: true }]);
await waitFor(() => expect(within(co).getByLabelText("Opt out of Change order waiting")).toBeChecked());
expect(within(co).getByText("Opted out")).toBeInTheDocument();
});
it("a PIN session sees the notifications but cannot change them", async () => {
mockFetch(routes(makeMe({ session: { kind: "pin" } })));
renderApp("/account");
const table = await screen.findByRole("table", { name: "Notifications" });
expect(await within(table).findByLabelText("Opt out of Change order waiting")).toBeDisabled();
expect(screen.getByText("Sign in with your password to change these.")).toBeInTheDocument();
});
});
+98 -1
View File
@@ -1,5 +1,6 @@
import { useState, type FormEvent } from "react";
import { Link } from "react-router";
import { accountNotifyApi, type MyNotification } from "../../api/admin";
import { accountApi, authApi } from "../../auth/api";
import { roleChipClass, sortRoles } from "../../auth/roles";
import type { AccountInfo, DeviceRef, Me } from "../../auth/types";
@@ -359,7 +360,102 @@ function AccessLogSection() {
);
}
/** /account: the user's own AD details, PIN, device registration, sessions and access log. */
function NotificationsSection({ me }: { me: Me }) {
const [load, reload] = useLoad((s) => accountNotifyApi.list(s));
const [busy, setBusy] = useState<string | null>(null);
const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null);
const isPinSession = me.session.kind === "pin";
const toggle = async (n: MyNotification, optOut: boolean) => {
setBusy(n.event_type);
setMsg(null);
try {
await accountNotifyApi.setOptOut(n.event_type, optOut);
setMsg({ ok: true, text: optOut ? `${n.label}: you will not get this mail.` : `${n.label}: you will get this mail again.` });
reload();
} catch (err) {
setMsg({ ok: false, text: errorText(err) });
} finally {
setBusy(null);
}
};
const rows = load.state === "ok" ? load.data : [];
return (
<section className="sec" aria-labelledby="acct-notify">
<div className="sec-h">
<h2 id="acct-notify">Notifications</h2>
<span className="ct">The mail Packet Station sends, and why it reaches you.</span>
</div>
{isPinSession ? <div className="callout">Sign in with your password to change these.</div> : null}
{msg ? (
<div className={msg.ok ? "callout" : "callout warn"} role={msg.ok ? "status" : "alert"}>
{msg.text}
</div>
) : null}
<Problem load={load} />
{load.state === "ok" ? (
<div className="tw">
<table aria-label="Notifications">
<thead>
<tr>
<th>Event</th>
<th>Delivery</th>
<th>Reaches you</th>
<th>Why</th>
<th>Your choice</th>
</tr>
</thead>
<tbody>
{rows.map((n) => (
<tr key={n.event_type}>
<td>
{n.label} {n.security ? <span className="pill st-bad">Security</span> : null}
</td>
<td className="small">{n.delivery === "digest" ? "Daily digest" : "At once"}</td>
<td>
{n.opted_out ? (
<span className="pill st-wait">Opted out</span>
) : n.reaches ? (
<span className="pill st-ok">Yes</span>
) : (
<span className="pill st-filed">No</span>
)}
</td>
<td className="small">{n.why.length ? n.why.join(", ") : <span className="muted">Not on the list</span>}</td>
<td>
{n.security || !n.can_opt_out ? (
<span className="small muted">{n.security ? "Security events cannot be turned off." : "-"}</span>
) : (
<label className="ckl">
<input
type="checkbox"
checked={n.opted_out}
disabled={isPinSession || busy === n.event_type}
onChange={(e) => void toggle(n, e.target.checked)}
/>
{`Opt out of ${n.label}`}
</label>
)}
</td>
</tr>
))}
{rows.length === 0 ? (
<tr>
<td colSpan={5} className="muted">
No notifications are turned on.
</td>
</tr>
) : null}
</tbody>
</table>
</div>
) : null}
</section>
);
}
/** /account: the user's own AD details, PIN, device registration, notifications, sessions and access log. */
export function AccountPage() {
const { me } = useAuth();
const [account, reloadAccount] = useLoad((s) => accountApi.get(s));
@@ -386,6 +482,7 @@ export function AccountPage() {
<Details me={me} account={account.state === "ok" ? account.data : null} />
<PinSection me={me} onSaved={reloadAccount} />
{me.can_register_device && me.session.kind === "password" ? <DeviceSection device={device} onRegistered={setRegistered} /> : null}
<NotificationsSection me={me} />
<SessionsSection me={me} />
<AccessLogSection />
<div className="small muted">
@@ -0,0 +1,63 @@
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { adminBase } from "../../test/admin";
import { renderApp } from "../../test/auth";
import { mockFetch, type MockRequest } from "../../test/fetchMock";
const ROW1 = {
id: 11,
at: "2026-10-21T09:00:00Z",
source: "attempt",
kind: "password",
ok: false,
account: "weld.walt",
user_id: "u-1",
display_name: "Walt Welder",
ip: "10.0.0.9",
detail: { reason: "bad_password" },
};
const ROW2 = { ...ROW1, id: 12, source: "audit", kind: "auth.signin", ok: true, at: "2026-10-20T09:00:00Z", detail: null };
describe("Admin Access log tab", () => {
it("loads, filters, pages and offers the CSV of the last search", async () => {
const seen: string[] = [];
const fetch = mockFetch({
...adminBase(),
"/api/admin/access-log": (req: MockRequest) => {
seen.push(req.url);
if (req.url.includes("before=")) return { body: { rows: [ROW2], next_before: null } };
return { body: { rows: [ROW1], next_before: "2026-10-21T09:00:00Z|11" } };
},
});
renderApp("/admin");
fireEvent.click(await screen.findByRole("tab", { name: "Access log" }));
const table = await screen.findByRole("table", { name: "Access log" });
expect(await within(table).findByText("Walt Welder")).toBeInTheDocument();
expect(within(table).getByText("Failed")).toHaveClass("st-bad");
expect(within(table).getByText("reason: bad_password")).toBeInTheDocument();
expect(screen.getByRole("link", { name: "Download CSV" })).toHaveAttribute("href", "/api/admin/access-log.csv");
fireEvent.change(screen.getByLabelText("Name or account"), { target: { value: "walt" } });
fireEvent.change(screen.getByRole("combobox", { name: /Result/ }), { target: { value: "false" } });
fireEvent.change(screen.getByLabelText("Address"), { target: { value: "10.0.0.9" } });
fireEvent.click(screen.getByRole("button", { name: "Search access log" }));
await waitFor(() => expect(seen.some((u) => u.includes("q=walt") && u.includes("ok=false") && u.includes("ip=10.0.0.9"))).toBe(true));
expect(screen.getByRole("link", { name: "Download CSV" })).toHaveAttribute("href", "/api/admin/access-log.csv?q=walt&ok=false&ip=10.0.0.9");
fireEvent.click(await screen.findByRole("button", { name: "Load older rows" }));
expect(await within(table).findByText("auth.signin")).toBeInTheDocument();
expect(within(table).getByText("password")).toBeInTheDocument();
const last = seen[seen.length - 1] ?? "";
expect(last).toContain("before=2026-10-21T09%3A00%3A00Z%7C11");
expect(last).toContain("q=walt");
expect(screen.queryByRole("button", { name: "Load older rows" })).not.toBeInTheDocument();
expect(fetch).toHaveBeenCalled();
});
it("shows an error from the server", async () => {
mockFetch({ ...adminBase(), "/api/admin/access-log": { status: 500, body: { detail: "Database unavailable" } } });
renderApp("/admin");
fireEvent.click(await screen.findByRole("tab", { name: "Access log" }));
expect(await screen.findByRole("alert")).toHaveTextContent("Database unavailable");
});
});
+178
View File
@@ -0,0 +1,178 @@
// Admin: the access log for everyone, with filters and a CSV download (phase 9).
import { useEffect, useState, type FormEvent } from "react";
import { admin9Api, type AccessLogFilters, type AccessLogRow } from "../../api/admin";
import { errorText, fmtDetail, fmtTime } from "../../lib/format";
import { Notice, type Msg } from "./common";
const PAGE = 200;
/** A datetime-local value as an ISO time, or undefined. */
function iso(local: string): string | undefined {
if (!local) return undefined;
const d = new Date(local);
return Number.isNaN(d.getTime()) ? undefined : d.toISOString();
}
interface Form {
since: string;
until: string;
q: string;
kind: string;
ok: "" | "true" | "false";
ip: string;
}
const EMPTY: Form = { since: "", until: "", q: "", kind: "", ok: "", ip: "" };
function toFilters(f: Form): AccessLogFilters {
const out: AccessLogFilters = {};
const since = iso(f.since);
const until = iso(f.until);
if (since) out.since = since;
if (until) out.until = until;
if (f.q.trim()) out.q = f.q.trim();
if (f.kind.trim()) out.kind = f.kind.trim();
if (f.ok) out.ok = f.ok;
if (f.ip.trim()) out.ip = f.ip.trim();
return out;
}
export function AccessLogTab() {
const [form, setForm] = useState<Form>(EMPTY);
const [filters, setFilters] = useState<AccessLogFilters>({});
const [rows, setRows] = useState<AccessLogRow[]>([]);
const [next, setNext] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [msg, setMsg] = useState<Msg>(null);
const fetchPage = async (f: AccessLogFilters, before: string | null) => {
setLoading(true);
setMsg(null);
try {
const r = await admin9Api.accessLog({ ...f, limit: PAGE, ...(before ? { before } : {}) });
setRows((cur) => (before ? [...cur, ...r.rows] : r.rows));
setNext(r.next_before);
} catch (err) {
setMsg({ ok: false, text: errorText(err) });
} finally {
setLoading(false);
}
};
// The first page, once. Later pages come from the search and the load-more button.
useEffect(() => {
void fetchPage({}, null);
}, []);
const set = (patch: Partial<Form>) => setForm((cur) => ({ ...cur, ...patch }));
const search = (e: FormEvent) => {
e.preventDefault();
const f = toFilters(form);
setFilters(f);
void fetchPage(f, null);
};
const csvHref = admin9Api.accessLogCsvUrl(filters);
return (
<div className="stack">
<form className="panel stack" onSubmit={search} noValidate role="search" aria-label="Access log filters">
<div className="frow">
<label className="f">
From
<input className="in" type="datetime-local" value={form.since} onChange={(e) => set({ since: e.target.value })} />
</label>
<label className="f">
To
<input className="in" type="datetime-local" value={form.until} onChange={(e) => set({ until: e.target.value })} />
</label>
<label className="f">
Name or account
<input className="in" type="search" value={form.q} onChange={(e) => set({ q: e.target.value })} />
</label>
<label className="f">
Kind
<input className="in mono" placeholder="auth.signin, password, pin" value={form.kind} onChange={(e) => set({ kind: e.target.value })} />
</label>
<label className="f">
Result
<select className="in" aria-label="Result" value={form.ok} onChange={(e) => set({ ok: e.target.value === "true" ? "true" : e.target.value === "false" ? "false" : "" })}>
<option value="">All</option>
<option value="true">OK only</option>
<option value="false">Failed only</option>
</select>
</label>
<label className="f">
Address
<input className="in mono" value={form.ip} onChange={(e) => set({ ip: e.target.value })} />
</label>
</div>
<div className="chips">
<button className="btn pri" type="submit" disabled={loading}>
Search access log
</button>
<button
className="btn ghost"
type="button"
onClick={() => {
setForm(EMPTY);
setFilters({});
void fetchPage({}, null);
}}
>
Clear filters
</button>
<a className="btn" href={csvHref} download>
Download CSV
</a>
</div>
<div className="small muted">The CSV has the rows the last search matched, up to 50,000. The download is logged.</div>
</form>
<Notice msg={msg} />
<div className="tw">
<table aria-label="Access log">
<thead>
<tr>
<th>When</th>
<th>Who</th>
<th>Kind</th>
<th>Result</th>
<th>Address</th>
<th>Detail</th>
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={`${r.source}-${r.id}`}>
<td className="small mono nowrap">{fmtTime(r.at)}</td>
<td>
{r.display_name ?? ""} {r.account ? <span className="mono small muted">{r.account}</span> : null}
</td>
<td className="mono small">{r.kind}</td>
<td>{r.ok ? <span className="pill st-ok">OK</span> : <span className="pill st-bad">Failed</span>}</td>
<td className="mono small">{r.ip ?? "-"}</td>
<td className="small">{fmtDetail(r.detail)}</td>
</tr>
))}
{!loading && rows.length === 0 ? (
<tr>
<td colSpan={6} className="muted">
Nothing matches.
</td>
</tr>
) : null}
</tbody>
</table>
</div>
{loading ? <div className="empty slim">Loading</div> : null}
{next && !loading ? (
<div>
<button className="btn sm" type="button" onClick={() => void fetchPage(filters, next)}>
Load older rows
</button>
</div>
) : null}
</div>
);
}
+26 -12
View File
@@ -55,9 +55,25 @@ function base(me = ADMIN) {
"/api/departments": { body: DEPARTMENTS },
"/api/admin/users": { body: USERS },
"/api/admin/groups": { body: GROUPS },
"/api/admin/ad/reconcile": { body: { run: null, report: null } },
};
}
const REPORT = {
run: { id: 7, started_at: "2026-10-21T01:30:00Z", finished_at: "2026-10-21T01:31:00Z", status: "ok" },
report: {
groups_missing: ["PS-14-Electrical-Lead"],
groups_extra: ["PS-Extra-Legacy"],
users_seen: 16,
created: 2,
updated: 14,
deactivated: 1,
sessions_ended: 3,
emp_missing: ["noemp.nora"],
emp_duplicate: ["9001"],
},
};
describe("AdminPage", () => {
it("lists users with employee ID flags and opens the detail", async () => {
mockFetch({
@@ -96,21 +112,15 @@ describe("AdminPage", () => {
expect(callsTo(fetch, "DELETE", "/api/admin/users/u-1/pin")).toEqual([{ reason: "Forgot the PIN, asked by phone" }]);
});
it("flags a group missing in AD, lists extras, and refreshes", async () => {
it("flags a group missing in AD, lists extras, requests a refresh and shows the reconcile report", async () => {
let refreshed = false;
const fetch = mockFetch({
...base(),
"POST /api/admin/ad/refresh": {
body: {
groups_checked: 50,
groups_missing: ["PS-14-Electrical-Lead"],
users_seen: 16,
created: 2,
updated: 14,
deactivated: 1,
emp_missing: ["noemp.nora"],
emp_duplicate: ["9001"],
},
"POST /api/admin/ad/refresh": () => {
refreshed = true;
return { status: 202, body: { requested: true, pending: 1, job: "ad-reconcile" } };
},
"/api/admin/ad/reconcile": () => ({ body: refreshed ? REPORT : { run: null, report: null } }),
});
renderApp("/admin");
fireEvent.click(await screen.findByRole("tab", { name: "Group map" }));
@@ -124,9 +134,13 @@ describe("AdminPage", () => {
expect(screen.getByText("PS-Extra-Legacy")).toBeInTheDocument();
expect(screen.getByText(/1 group is missing in AD/)).toBeInTheDocument();
expect(await screen.findByText(/Not run yet/)).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Refresh from AD now" }));
expect(await screen.findByText(/Refresh requested/)).toBeInTheDocument();
expect(await screen.findByText("Users seen")).toBeInTheDocument();
expect(screen.getByText("16")).toBeInTheDocument();
expect(screen.getByText("Sessions ended")).toBeInTheDocument();
expect(screen.getByText(/noemp\.nora/)).toBeInTheDocument();
expect(callsTo(fetch, "POST", "/api/admin/ad/refresh")).toHaveLength(1);
});
+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>
</>
);
@@ -0,0 +1,94 @@
import { fireEvent, screen, waitFor } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { adminBase } from "../../test/admin";
import { renderApp } from "../../test/auth";
import { callsTo, mockFetch } from "../../test/fetchMock";
const VALUE = {
weld: [
{ group: "Before welding", key: "dwg", text: "Drawing {drawing} Rev {rev} matches the packet" },
{ group: "Before welding", key: "wps", text: "WPS reviewed: {wps}" },
{ group: "After welding", key: "vt", text: "Visual inspection of all welds acceptable" },
],
plain: [{ group: "Before starting", key: "dwg", text: "Drawing {drawing} Rev {rev} matches the packet" }],
};
const DEFAULTS = { weld: VALUE.weld.slice(0, 2), plain: VALUE.plain };
async function openTab() {
renderApp("/admin");
fireEvent.click(await screen.findByRole("tab", { name: "Checklists" }));
return screen.findByLabelText("Weld item 1 text");
}
describe("Admin Checklists tab", () => {
it("edits, adds, reorders and removes items, checks them, and saves with the version", async () => {
const fetch = mockFetch({
...adminBase(),
"GET /api/admin/checklists": { body: { value: VALUE, defaults: DEFAULTS, version: 4 } },
"PUT /api/admin/checklists": { body: { value: VALUE, defaults: DEFAULTS, version: 5 } },
});
await openTab();
expect(screen.getByLabelText("Weld item 1 key")).toHaveAttribute("readonly");
expect(screen.getByLabelText("Plain item 1 group")).toHaveValue("Before starting");
fireEvent.change(screen.getByLabelText("Weld item 3 text"), { target: { value: "Visual check of every weld" } });
fireEvent.click(screen.getByRole("button", { name: "Move weld item 3 up" }));
fireEvent.click(screen.getByRole("button", { name: "Remove weld item 1" }));
fireEvent.click(screen.getByRole("button", { name: "Add weld item" }));
const key = screen.getByLabelText("Weld item 3 key");
expect(key).not.toHaveAttribute("readonly");
fireEvent.change(key, { target: { value: "Clean" } });
fireEvent.change(screen.getByLabelText("Weld item 3 text"), { target: { value: "Slag removed near {part}" } });
fireEvent.click(screen.getByRole("button", { name: "Save checklists" }));
expect(await screen.findByRole("alert")).toHaveTextContent("Weld list, item 3: the key is lower case");
fireEvent.change(key, { target: { value: "clean" } });
fireEvent.click(screen.getByRole("button", { name: "Save checklists" }));
expect(await screen.findByRole("alert")).toHaveTextContent("only {drawing}, {rev} and {wps}");
fireEvent.change(screen.getByLabelText("Weld item 3 text"), { target: { value: "Slag removed" } });
fireEvent.click(screen.getByRole("button", { name: "Save checklists" }));
expect(await screen.findByText(/Checklists saved/)).toBeInTheDocument();
expect(callsTo(fetch, "PUT", "/api/admin/checklists")).toEqual([
{
version: 4,
value: {
weld: [
{ group: "After welding", key: "vt", text: "Visual check of every weld" },
{ group: "Before welding", key: "wps", text: "WPS reviewed: {wps}" },
{ group: "Before welding", key: "clean", text: "Slag removed" },
],
plain: VALUE.plain,
},
},
]);
});
it("loads the other version after a 409", async () => {
let gets = 0;
const fetch = mockFetch({
...adminBase(),
"GET /api/admin/checklists": () => {
gets += 1;
return { body: { value: gets > 1 ? { ...VALUE, plain: [{ ...VALUE.plain[0], text: "Changed by Bob" }] } : VALUE, defaults: DEFAULTS, version: 3 + gets } };
},
"PUT /api/admin/checklists": { status: 409, body: { detail: "Changed by someone else", code: "version_conflict" } },
});
await openTab();
fireEvent.change(screen.getByLabelText("Plain item 1 text"), { target: { value: "Mine" } });
fireEvent.click(screen.getByRole("button", { name: "Save checklists" }));
expect(await screen.findByRole("alert")).toHaveTextContent("Someone else saved the checklists first.");
await waitFor(() => expect(screen.getByLabelText("Plain item 1 text")).toHaveValue("Changed by Bob"));
expect(callsTo(fetch, "GET", "/api/admin/checklists")).toHaveLength(2);
});
it("copies the default list in", async () => {
mockFetch({ ...adminBase(), "GET /api/admin/checklists": { body: { value: VALUE, defaults: DEFAULTS, version: 1 } } });
await openTab();
expect(screen.getByLabelText("Weld item 3 text")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Use the default weld list" }));
expect(screen.queryByLabelText("Weld item 3 text")).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Discard changes" }));
expect(screen.getByLabelText("Weld item 3 text")).toBeInTheDocument();
});
});
@@ -0,0 +1,231 @@
// Admin: the department checklists, one list for weld departments and one for the rest (phase 9).
import { useEffect, useState } from "react";
import { admin9Api, type ChecklistItem, type ChecklistLists, type ChecklistsAnswer } from "../../api/admin";
import { ApiError } from "../../api/client";
import { errorText } from "../../lib/format";
import { useLoad } from "../../lib/useLoad";
import { Notice, Problem, type Msg } from "./common";
type ListId = "weld" | "plain";
const LISTS: ReadonlyArray<{ id: ListId; title: string; one: string }> = [
{ id: "weld", title: "Weld departments", one: "weld" },
{ id: "plain", title: "Other departments", one: "plain" },
];
const RX_KEY = /^[a-z][a-z0-9_]{0,29}$/;
const MAX_ITEMS = 40;
const cap = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
const PLACEHOLDERS = new Set(["drawing", "rev", "wps"]);
interface Row extends ChecklistItem {
/** True for a key the saved list already has. Its key cannot change, because answers on open stages use it. */
saved: boolean;
}
type Draft = Record<ListId, Row[]>;
function toDraft(v: ChecklistLists): Draft {
return {
weld: v.weld.map((it) => ({ ...it, saved: true })),
plain: v.plain.map((it) => ({ ...it, saved: true })),
};
}
/** The first problem with one list, or null. Mirrors the server's rules. */
function listProblem(rows: readonly ChecklistItem[], title: string): string | null {
if (rows.length === 0) return `${title}: the list needs at least one item.`;
if (rows.length > MAX_ITEMS) return `${title}: at most ${MAX_ITEMS} items.`;
const seen = new Set<string>();
for (const [i, r] of rows.entries()) {
const n = i + 1;
if (!r.group.trim()) return `${title}, item ${n}: give it a group.`;
if (!r.text.trim()) return `${title}, item ${n}: give it a text.`;
if (!RX_KEY.test(r.key)) return `${title}, item ${n}: the key is lower case letters, digits and _, starting with a letter.`;
if (seen.has(r.key)) return `${title}, item ${n}: the key ${r.key} is used twice.`;
seen.add(r.key);
for (const m of r.text.matchAll(/\{([^}]*)\}/g)) {
if (!PLACEHOLDERS.has(m[1] ?? "")) return `${title}, item ${n}: only {drawing}, {rev} and {wps} can be filled in.`;
}
}
return null;
}
function ListEditor({
id,
title,
one,
rows,
onChange,
onDefaults,
}: {
id: ListId;
title: string;
one: string;
rows: Row[];
onChange: (rows: Row[]) => void;
onDefaults: () => void;
}) {
const set = (i: number, patch: Partial<Row>) => onChange(rows.map((r, j) => (j === i ? { ...r, ...patch } : r)));
const move = (i: number, d: number) => {
const j = i + d;
if (j < 0 || j >= rows.length) return;
const out = [...rows];
const a = out[i];
const b = out[j];
if (!a || !b) return;
out[i] = b;
out[j] = a;
onChange(out);
};
const add = () => {
const last = rows[rows.length - 1];
onChange([...rows, { group: last?.group ?? "", key: "", text: "", saved: false }]);
};
return (
<section className="panel stack" aria-labelledby={`ckl-${id}-h`}>
<div className="sec-h flush">
<h3 id={`ckl-${id}-h`}>{title}</h3>
<span className="ct">{rows.length} items</span>
<span className="grow" />
<button className="btn sm ghost" type="button" onClick={onDefaults}>
{`Use the default ${one} list`}
</button>
</div>
{rows.map((r, i) => {
const n = i + 1;
return (
<div className="frow" key={`${i}-${r.saved ? r.key : "new"}`}>
<label className="f">
{`${cap(one)} item ${n} group`}
<input className="in" value={r.group} maxLength={200} onChange={(e) => set(i, { group: e.target.value })} />
</label>
<label className="f">
{`${cap(one)} item ${n} key`}
<input
className="in mono"
value={r.key}
maxLength={30}
readOnly={r.saved}
title={r.saved ? "A saved key cannot change. Answers on open stages use it." : undefined}
onChange={(e) => set(i, { key: e.target.value })}
/>
</label>
<label className="f" style={{ gridColumn: "span 2" }}>
{`${cap(one)} item ${n} text`}
<input className="in" value={r.text} maxLength={200} onChange={(e) => set(i, { text: e.target.value })} />
</label>
<div className="end nowrap">
<button className="btn sm ghost" type="button" aria-label={`Move ${one} item ${n} up`} disabled={i === 0} onClick={() => move(i, -1)}>
Up
</button>{" "}
<button
className="btn sm ghost"
type="button"
aria-label={`Move ${one} item ${n} down`}
disabled={i === rows.length - 1}
onClick={() => move(i, 1)}
>
Down
</button>{" "}
<button className="btn sm rejb" type="button" aria-label={`Remove ${one} item ${n}`} onClick={() => onChange(rows.filter((_, j) => j !== i))}>
Remove
</button>
</div>
</div>
);
})}
<div>
<button className="btn sm" type="button" disabled={rows.length >= MAX_ITEMS} onClick={add}>
{`Add ${one} item`}
</button>
</div>
</section>
);
}
export function ChecklistsTab() {
const [load, reload] = useLoad((s) => admin9Api.checklists(s));
const [base, setBase] = useState<ChecklistsAnswer | null>(null);
const [draft, setDraft] = useState<Draft | null>(null);
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState<Msg>(null);
useEffect(() => {
if (load.state === "ok") {
setBase(load.data);
setDraft(toDraft(load.data.value));
}
}, [load]);
if (!base || !draft) return <Problem load={load} />;
const strip = (rows: Row[]): ChecklistItem[] => rows.map((r) => ({ group: r.group.trim(), key: r.key.trim(), text: r.text.trim() }));
const savedKeys = new Set([...base.value.weld, ...base.value.plain].map((r) => r.key));
const save = async () => {
const value: ChecklistLists = { weld: strip(draft.weld), plain: strip(draft.plain) };
const problem = listProblem(value.weld, "Weld list") ?? listProblem(value.plain, "Other list");
if (problem) {
setMsg({ ok: false, text: problem });
return;
}
setBusy(true);
setMsg(null);
try {
const r = await admin9Api.saveChecklists(value, base.version);
setBase(r);
setDraft(toDraft(r.value));
setMsg({ ok: true, text: "Checklists saved. New checklists use them. Open stages keep their answers." });
} catch (err) {
if (err instanceof ApiError && err.status === 409) {
setMsg({ ok: false, text: "Someone else saved the checklists first. Their version is loaded now. Make your change again." });
reload();
} else {
setMsg({ ok: false, text: errorText(err) });
}
} finally {
setBusy(false);
}
};
const defaults = (id: ListId) =>
setDraft((cur) => (cur ? { ...cur, [id]: base.defaults[id].map((it) => ({ ...it, saved: savedKeys.has(it.key) })) } : cur));
return (
<div className="stack">
<div className="small muted">
Weld departments get the first list, the others the second. Text can use {"{drawing}"}, {"{rev}"} and {"{wps}"}. A removed item drops out of new
checklists only.
</div>
{LISTS.map((l) => (
<ListEditor
key={l.id}
id={l.id}
title={l.title}
one={l.one}
rows={draft[l.id]}
onChange={(rows) => setDraft((cur) => (cur ? { ...cur, [l.id]: rows } : cur))}
onDefaults={() => defaults(l.id)}
/>
))}
<Notice msg={msg} />
<div className="chips">
<button className="btn pri" type="button" disabled={busy} onClick={() => void save()}>
Save checklists
</button>
<button
className="btn ghost"
type="button"
disabled={busy}
onClick={() => {
setDraft(toDraft(base.value));
setMsg(null);
}}
>
Discard changes
</button>
</div>
</div>
);
}
@@ -0,0 +1,110 @@
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { ADMIN_DEPTS, adminBase } from "../../test/admin";
import { renderApp } from "../../test/auth";
import { callsTo, mockFetch } from "../../test/fetchMock";
async function openTab() {
renderApp("/admin");
fireEvent.click(await screen.findByRole("tab", { name: "Departments" }));
return screen.findByRole("table", { name: "Departments" });
}
describe("Admin Departments tab", () => {
it("lists departments with their groups and active stations", async () => {
mockFetch({ ...adminBase(), "/api/admin/departments": { body: ADMIN_DEPTS } });
const table = await openTab();
expect(screen.getByRole("tab", { name: "Departments" })).toHaveAttribute("id", "admtab-departments");
const row = within(table).getByText("03").closest("tr") as HTMLElement;
expect(within(row).getByText("Stall 1, Stall 2")).toBeInTheDocument();
const sup = within(row).getByText("PS-03-WeldParts-Supervisor").closest("li") as HTMLElement;
expect(within(sup).getByText("Missing in AD")).toHaveClass("st-bad");
const cl = within(table).getByText("PS-Clerical").closest("li") as HTMLElement;
expect(within(cl).getByText("Not checked")).toBeInTheDocument();
});
it("edits the name and stations and keeps station ids", async () => {
const fetch = mockFetch({
...adminBase(),
"/api/admin/departments": { body: ADMIN_DEPTS },
"PATCH /api/admin/departments/03": { body: ADMIN_DEPTS[0] },
});
await openTab();
fireEvent.click(screen.getByRole("button", { name: "Edit department 03" }));
fireEvent.change(screen.getByLabelText("Department name"), { target: { value: "Weld Parts East" } });
fireEvent.click(screen.getByRole("button", { name: "Move station 2 up" }));
fireEvent.click(screen.getByRole("button", { name: "Add station" }));
fireEvent.change(screen.getByLabelText("Station 4 name"), { target: { value: "Stall 1" } });
fireEvent.click(screen.getByRole("button", { name: "Save department" }));
expect(await screen.findByRole("alert")).toHaveTextContent("Two stations have the same name.");
fireEvent.change(screen.getByLabelText("Station 4 name"), { target: { value: "Stall 4" } });
fireEvent.click(screen.getByLabelText("Station 3 active"));
fireEvent.click(screen.getByRole("button", { name: "Save department" }));
expect(await screen.findByText("Department saved.")).toBeInTheDocument();
expect(callsTo(fetch, "PATCH", "/api/admin/departments/03")).toEqual([
{
name: "Weld Parts East",
active: true,
stations: [
{ id: 32, name: "Stall 2", sort: 1, active: true },
{ id: 31, name: "Stall 1", sort: 2, active: true },
{ id: 33, name: "Old Stall", sort: 3, active: true },
{ name: "Stall 4", sort: 4, active: true },
],
},
]);
});
it("adds a department and shows the groups IT must create", async () => {
const fetch = mockFetch({
...adminBase(),
"/api/admin/departments": { body: ADMIN_DEPTS },
"POST /api/admin/departments": {
status: 201,
body: {
department: { ...ADMIN_DEPTS[0], code: "15", slug: "Paint", name: "Paint", groups: [], stations: [] },
groups_to_create: ["PS-15-Paint-Worker", "PS-15-Paint-Lead", "PS-15-Paint-Supervisor"],
},
},
});
await openTab();
fireEvent.click(screen.getByRole("button", { name: "New department" }));
const form = screen.getByRole("form", { name: "Add department" });
fireEvent.change(within(form).getByLabelText("Code"), { target: { value: "1" } });
fireEvent.click(within(form).getByRole("button", { name: "Add department" }));
expect(await within(form).findByRole("alert")).toHaveTextContent("The code is 2 to 4");
expect(callsTo(fetch, "POST", "/api/admin/departments")).toHaveLength(0);
fireEvent.change(within(form).getByLabelText("Code"), { target: { value: "15" } });
fireEvent.change(within(form).getByLabelText("Short name"), { target: { value: "Paint" } });
fireEvent.change(within(form).getByLabelText("Name"), { target: { value: "Paint" } });
fireEvent.change(within(form).getByLabelText("Stations, one per line"), { target: { value: "Booth 1\n\nBooth 2\n" } });
expect(within(form).getByText(/PS-15-Paint-Worker, PS-15-Paint-Lead, PS-15-Paint-Supervisor/)).toBeInTheDocument();
fireEvent.click(within(form).getByRole("button", { name: "Add department" }));
const done = await screen.findByRole("status", { name: "Groups to create" });
expect(within(done).getByText("PS-15-Paint-Supervisor")).toBeInTheDocument();
await waitFor(() =>
expect(callsTo(fetch, "POST", "/api/admin/departments")).toEqual([
{ code: "15", slug: "Paint", name: "Paint", kind: "shop", weld: false, supervisor_group: true, stations: ["Booth 1", "Booth 2"] },
]),
);
});
it("shows the server's answer when a code is taken", async () => {
mockFetch({
...adminBase(),
"/api/admin/departments": { body: ADMIN_DEPTS },
"POST /api/admin/departments": { status: 409, body: { detail: "Code 03 is taken.", code: "conflict" } },
});
await openTab();
fireEvent.click(screen.getByRole("button", { name: "New department" }));
const form = screen.getByRole("form", { name: "Add department" });
fireEvent.change(within(form).getByLabelText("Code"), { target: { value: "03" } });
fireEvent.change(within(form).getByLabelText("Short name"), { target: { value: "Again" } });
fireEvent.change(within(form).getByLabelText("Name"), { target: { value: "Again" } });
fireEvent.click(within(form).getByRole("button", { name: "Add department" }));
expect(await within(form).findByRole("alert")).toHaveTextContent("Code 03 is taken.");
});
});
@@ -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>
);
}
@@ -0,0 +1,277 @@
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { adminBase } from "../../test/admin";
import { renderApp } from "../../test/auth";
import { callsTo, mockFetch, type MockRequest } from "../../test/fetchMock";
const ADMINS = { id: 1, kind: "group", group_dn: "CN=PS-Admins,OU=PacketStation,DC=pstest,DC=local", group_name: "PS-Admins", user_id: null, display_name: null, address: null };
const SECURITY_RULE = {
event_type: "security_alert",
label: "Security alert",
description: "Account cooldowns, disabled PINs and ended sessions.",
security: true,
dept_scoped: false,
param: null,
enabled: true,
delivery: "immediate",
digest_time: "07:00",
recipients: [ADMINS],
exclusions: [],
version: 1,
last_sent: { at: "2026-10-21T08:00:00Z", result: "sent" },
};
const CO_RULE = {
event_type: "co_waiting",
label: "Change order waiting",
description: "A change order pending review too long.",
security: false,
dept_scoped: true,
param: { name: "days", value: 3, default: 3 },
enabled: true,
delivery: "immediate",
digest_time: "07:00",
recipients: [ADMINS],
exclusions: [{ user_id: "u-5", display_name: "Sam Self", self: true }],
version: 4,
last_sent: null,
};
const ANSWER = {
dry_run: true,
smtp: { host: "app-03.pesco.com", port: 25, from: "[email protected]", starttls: true },
subject_prefix: "[Packet Station TEST]",
rules: [SECURITY_RULE, CO_RULE],
};
const SENT = {
rows: [
{
id: 3,
at: "2026-10-21T08:00:00Z",
event_type: "security_alert",
digest: false,
test: false,
subject: "[Packet Station TEST] Account cooldown: weld.walt",
recipients: [{ address: "[email protected]", user_id: "u-0", why: "group PS-Admins" }],
result: "sent",
error: null,
},
],
next_before: null,
};
const PICKABLE = [
{
id: "u-7",
sam_account: "sup.sue",
display_name: "Sue Super",
emp_no: "03100",
email: "[email protected]",
active: true,
roles: ["shop:03:supervisor"],
pin: { set: false, disabled: false },
flags: { emp_missing: false, emp_duplicate: false, disabled: false },
last_signin_at: null,
ad_synced_at: null,
},
];
function routes(extra: Parameters<typeof mockFetch>[0] = {}) {
return {
...adminBase(),
"GET /api/admin/notifications": { body: ANSWER },
"/api/admin/notifications/sent": { body: SENT },
"/api/admin/users": (req: MockRequest) => ({ body: req.url.includes("q=") ? PICKABLE : [] }),
...extra,
};
}
async function openTab() {
renderApp("/admin");
fireEvent.click(await screen.findByRole("tab", { name: "Notifications" }));
return screen.findByRole("table", { name: "Notification rules" });
}
describe("Admin Notifications tab", () => {
it("lists one row per event type with the mail settings and the sent log", async () => {
mockFetch(routes());
const table = await openTab();
expect(screen.getByRole("tab", { name: "Notifications" })).toHaveAttribute("id", "admtab-notifications");
expect(screen.getByText(/Dry run: messages are logged/)).toBeInTheDocument();
expect(screen.getByText("app-03.pesco.com:25")).toBeInTheDocument();
const sec = within(table).getByText("Security alert").closest("tr") as HTMLElement;
expect(within(sec).getByText("Group PS-Admins")).toBeInTheDocument();
expect(within(sec).getByText("At once")).toBeInTheDocument();
const co = within(table).getByText("Change order waiting").closest("tr") as HTMLElement;
expect(within(co).getByText("After 3 days")).toBeInTheDocument();
expect(within(co).getByText("Never")).toBeInTheDocument();
const log = await screen.findByRole("table", { name: "Sent log" });
expect(await within(log).findByText("[email protected]")).toBeInTheDocument();
});
it("edits delivery, the parameter, recipients of every kind and exclusions, then saves with the version", async () => {
const fetch = mockFetch(
routes({
"/api/admin/notifications/groups": {
body: [{ dn: "CN=PS-03-WeldParts-Supervisor,OU=PacketStation,DC=pstest,DC=local", name: "PS-03-WeldParts-Supervisor", description: "Weld supervisors" }],
},
"PUT /api/admin/notifications/co_waiting": { body: { ...CO_RULE, version: 5 } },
}),
);
await openTab();
fireEvent.click(screen.getByRole("button", { name: "Edit Change order waiting" }));
const ed = await screen.findByRole("region", { name: "Change order waiting" });
fireEvent.change(within(ed).getByRole("combobox", { name: /Delivery/ }), { target: { value: "digest" } });
fireEvent.change(within(ed).getByLabelText("Digest time"), { target: { value: "06:30" } });
fireEvent.change(within(ed).getByLabelText("Raise after, days"), { target: { value: "5" } });
expect(within(ed).getByText("Default 3.")).toBeInTheDocument();
// AD group
fireEvent.change(within(ed).getByLabelText("Search AD groups"), { target: { value: "x" } });
fireEvent.click(within(ed).getByRole("button", { name: "Search groups" }));
expect(await within(ed).findByText("Type at least 2 letters.")).toBeInTheDocument();
fireEvent.change(within(ed).getByLabelText("Search AD groups"), { target: { value: "weld" } });
fireEvent.click(within(ed).getByRole("button", { name: "Search groups" }));
fireEvent.click(await within(ed).findByRole("button", { name: "Add group PS-03-WeldParts-Supervisor" }));
expect(callsTo(fetch, "GET", "/api/admin/notifications/groups")).toHaveLength(1);
// User
const kind = within(ed).getByRole("combobox", { name: /Add a recipient/ });
fireEvent.change(kind, { target: { value: "user" } });
fireEvent.change(within(ed).getByLabelText("Find a user to add"), { target: { value: "sue" } });
fireEvent.click(await within(ed).findByRole("button", { name: "Pick Sue Super" }));
// Mailbox
fireEvent.change(kind, { target: { value: "mailbox" } });
fireEvent.change(within(ed).getByLabelText("Mailbox address"), { target: { value: "not an address" } });
fireEvent.click(within(ed).getByRole("button", { name: "Add mailbox" }));
expect(await within(ed).findByText("That address does not look right.")).toBeInTheDocument();
fireEvent.change(within(ed).getByLabelText("Mailbox address"), { target: { value: "[email protected]" } });
fireEvent.click(within(ed).getByRole("button", { name: "Add mailbox" }));
// Department supervisor (offered because the event is department scoped)
fireEvent.change(kind, { target: { value: "dept_supervisor" } });
fireEvent.click(within(ed).getByRole("button", { name: "Add the department Supervisor group" }));
fireEvent.click(within(ed).getByRole("button", { name: "Remove recipient Group PS-Admins" }));
const recips = within(ed).getByRole("list", { name: "Recipients" });
expect(within(recips).getAllByRole("listitem")).toHaveLength(4);
// Exclusions: a self opt-out stays; add one
const excl = within(ed).getByRole("list", { name: "Exclusions" });
expect(within(excl).getByText("Opted out")).toBeInTheDocument();
fireEvent.change(within(ed).getByLabelText("Find a user to exclude"), { target: { value: "sue" } });
fireEvent.click(await within(ed).findByRole("button", { name: "Pick Sue Super" }));
expect(within(excl).getByRole("button", { name: "Remove exclusion Sue Super" })).toBeInTheDocument();
fireEvent.click(within(ed).getByRole("button", { name: "Save rule" }));
expect(await screen.findByText("Change order waiting: saved.")).toBeInTheDocument();
expect(callsTo(fetch, "PUT", "/api/admin/notifications/co_waiting")).toEqual([
{
enabled: true,
delivery: "digest",
digest_time: "06:30",
param: 5,
recipients: [
{ kind: "group", group_dn: "CN=PS-03-WeldParts-Supervisor,OU=PacketStation,DC=pstest,DC=local", group_name: "PS-03-WeldParts-Supervisor" },
{ kind: "user", user_id: "u-7" },
{ kind: "mailbox", address: "[email protected]" },
{ kind: "dept_supervisor" },
],
exclusions: ["u-5", "u-7"],
version: 4,
},
]);
});
it("allows no exclusions on a security event and offers no department kind", async () => {
mockFetch(routes());
await openTab();
fireEvent.click(screen.getByRole("button", { name: "Edit Security alert" }));
const ed = await screen.findByRole("region", { name: "Security alert" });
expect(within(ed).getByText(/Nobody can be excluded or opt out/)).toBeInTheDocument();
expect(within(ed).queryByLabelText("Find a user to exclude")).not.toBeInTheDocument();
expect(within(ed).queryByLabelText(/Raise after/)).not.toBeInTheDocument();
const kind = within(ed).getByRole("combobox", { name: /Add a recipient/ });
expect(within(kind).queryByRole("option", { name: "Department Supervisor group" })).not.toBeInTheDocument();
});
it("reloads on a 409", async () => {
const fetch = mockFetch(routes({ "PUT /api/admin/notifications/security_alert": { status: 409, body: { detail: "Changed", code: "version_conflict" } } }));
await openTab();
fireEvent.click(screen.getByRole("button", { name: "Edit Security alert" }));
const ed = await screen.findByRole("region", { name: "Security alert" });
fireEvent.click(within(ed).getByLabelText("Rule enabled"));
fireEvent.click(within(ed).getByRole("button", { name: "Save rule" }));
expect(await screen.findByText(/someone else saved it first/)).toBeInTheDocument();
await waitFor(() => expect(callsTo(fetch, "GET", "/api/admin/notifications")).toHaveLength(2));
expect(callsTo(fetch, "PUT", "/api/admin/notifications/security_alert")).toEqual([
{ enabled: false, delivery: "immediate", digest_time: "07:00", param: null, recipients: [{ kind: "group", group_dn: ADMINS.group_dn, group_name: "PS-Admins" }], exclusions: [], version: 1 },
]);
});
it("sends a test to an address and shows the result and error", async () => {
const fetch = mockFetch(
routes({
"POST /api/admin/notifications/security_alert/test": {
body: { result: "failed", subject: "[Packet Station TEST] Test: Security alert", recipients: [{ address: "[email protected]" }], error: "connection refused", sent_id: 9 },
},
}),
);
await openTab();
fireEvent.click(screen.getByRole("button", { name: "Edit Security alert" }));
const ed = await screen.findByRole("region", { name: "Security alert" });
fireEvent.change(within(ed).getByLabelText("Send the test to"), { target: { value: "[email protected]" } });
fireEvent.click(within(ed).getByRole("button", { name: "Send a test" }));
const res = await within(ed).findByRole("status", { name: "Test result" });
expect(res).toHaveTextContent("failed");
expect(res).toHaveTextContent("Error: connection refused");
expect(res).toHaveTextContent("To: [email protected]");
expect(callsTo(fetch, "POST", "/api/admin/notifications/security_alert/test")).toEqual([{ to: "[email protected]" }]);
await waitFor(() => expect(callsTo(fetch, "GET", "/api/admin/notifications/sent").length).toBeGreaterThanOrEqual(2));
});
it("sends a test to the saved recipients with no address", async () => {
const fetch = mockFetch(
routes({
"POST /api/admin/notifications/co_waiting/test": {
body: { result: "sent", subject: "s", recipients: [{ address: "[email protected]", why: "group PS-Admins" }], error: null, sent_id: 10 },
},
}),
);
await openTab();
fireEvent.click(screen.getByRole("button", { name: "Edit Change order waiting" }));
const ed = await screen.findByRole("region", { name: "Change order waiting" });
fireEvent.click(within(ed).getByRole("button", { name: "Send a test" }));
expect(await within(ed).findByText("To: [email protected] (group PS-Admins)")).toBeInTheDocument();
expect(callsTo(fetch, "POST", "/api/admin/notifications/co_waiting/test")).toEqual([{}]);
});
it("says when AD is down during a group search", async () => {
mockFetch(routes({ "/api/admin/notifications/groups": { status: 503, body: { detail: "Directory unavailable" } } }));
await openTab();
fireEvent.click(screen.getByRole("button", { name: "Edit Security alert" }));
const ed = await screen.findByRole("region", { name: "Security alert" });
fireEvent.change(within(ed).getByLabelText("Search AD groups"), { target: { value: "admins" } });
fireEvent.click(within(ed).getByRole("button", { name: "Search groups" }));
expect(await within(ed).findByText("AD is unreachable. Try again later.")).toBeInTheDocument();
});
it("filters the sent log by event type", async () => {
const urls: string[] = [];
mockFetch(
routes({
"/api/admin/notifications/sent": (req: MockRequest) => {
urls.push(req.url);
return { body: SENT };
},
}),
);
await openTab();
fireEvent.change(screen.getByRole("combobox", { name: /Event filter/ }), { target: { value: "co_waiting" } });
await waitFor(() => expect(urls.some((u) => u.includes("event_type=co_waiting"))).toBe(true));
});
});
@@ -0,0 +1,591 @@
// Admin: who gets mail for each event type, how, and what was sent (phase 9).
import { useEffect, useState, type FormEvent } from "react";
import {
admin9Api,
type AdGroupHit,
type NotifyAnswer,
type NotifyRule,
type Recipient,
type RecipientKind,
type RulePut,
type SentRow,
type TestSendAnswer,
} from "../../api/admin";
import { ApiError } from "../../api/client";
import { errorText, fmtTime } from "../../lib/format";
import { useLoad } from "../../lib/useLoad";
import { Notice, Problem, UserPicker, type Msg } from "./common";
const RX_ADDRESS = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const RX_TIME = /^([01]\d|2[0-3]):[0-5]\d$/;
const KIND_LABEL: Record<RecipientKind, string> = {
group: "AD group",
user: "User",
mailbox: "Mailbox",
dept_supervisor: "Department Supervisor group",
};
function recipientText(r: Recipient): string {
switch (r.kind) {
case "group":
return `Group ${r.group_name ?? r.group_dn ?? ""}`;
case "user":
return `User ${r.display_name ?? r.user_id ?? ""}`;
case "mailbox":
return `Mailbox ${r.address ?? ""}`;
case "dept_supervisor":
return "Supervisor group of the event's department";
}
}
function deliveryText(r: NotifyRule): string {
return r.delivery === "digest" ? `Daily digest at ${r.digest_time}` : "At once";
}
function resultPill(result: string) {
const cls = result === "sent" ? "pill st-ok" : result === "dry_run" || result === "no_recipients" ? "pill st-wait" : "pill st-bad";
return <span className={cls}>{result.replace(/_/g, " ")}</span>;
}
/* ---------- AD group search ---------- */
function GroupSearch({ onPick }: { onPick: (g: AdGroupHit) => void }) {
const [q, setQ] = useState("");
const [hits, setHits] = useState<AdGroupHit[] | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const search = async () => {
if (q.trim().length < 2) {
setError("Type at least 2 letters.");
return;
}
setBusy(true);
setError(null);
try {
setHits(await admin9Api.searchGroups(q.trim()));
} catch (err) {
setHits(null);
setError(err instanceof ApiError && err.status === 503 ? "AD is unreachable. Try again later." : errorText(err));
} finally {
setBusy(false);
}
};
return (
<div className="stack tight">
<div className="frow">
<label className="f">
Search AD groups
<input
className="in"
type="search"
value={q}
onChange={(e) => setQ(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
void search();
}
}}
/>
</label>
<div className="end">
<button className="btn sm" type="button" disabled={busy} onClick={() => void search()}>
Search groups
</button>
</div>
</div>
{error ? <div className="callout warn flush">{error}</div> : null}
{hits && hits.length === 0 ? <div className="small muted">No group matches.</div> : null}
{hits && hits.length ? (
<ul className="stack tight" aria-label="Groups found" style={{ listStyle: "none", margin: 0, padding: 0 }}>
{hits.map((g) => (
<li key={g.dn}>
<button className="btn sm ghost" type="button" aria-label={`Add group ${g.name}`} onClick={() => onPick(g)}>
Add
</button>{" "}
<span className="mono small">{g.name}</span> {g.description ? <span className="small muted">{g.description}</span> : null}
</li>
))}
</ul>
) : null}
</div>
);
}
/* ---------- One rule ---------- */
type Excl = { user_id: string; display_name: string | null; self: boolean };
function RuleEditor({
rule,
onSaved,
onConflict,
onTested,
onClose,
}: {
rule: NotifyRule;
onSaved: () => void;
onConflict: () => void;
onTested: () => void;
onClose: () => void;
}) {
const [enabled, setEnabled] = useState(rule.enabled);
const [delivery, setDelivery] = useState(rule.delivery);
const [digestTime, setDigestTime] = useState(rule.digest_time);
const [param, setParam] = useState(rule.param ? String(rule.param.value) : "");
const [recipients, setRecipients] = useState<Recipient[]>(rule.recipients);
const [exclusions, setExclusions] = useState<Excl[]>(rule.exclusions);
const [addKind, setAddKind] = useState<RecipientKind>("group");
const [mailbox, setMailbox] = useState("");
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState<Msg>(null);
const [testTo, setTestTo] = useState("");
const [test, setTest] = useState<TestSendAnswer | null>(null);
const [testMsg, setTestMsg] = useState<Msg>(null);
const kinds: RecipientKind[] = rule.dept_scoped ? ["group", "user", "mailbox", "dept_supervisor"] : ["group", "user", "mailbox"];
const addRecipient = (r: Recipient) => {
const same = (a: Recipient) =>
a.kind === r.kind &&
(r.kind === "group"
? a.group_dn?.toLowerCase() === r.group_dn?.toLowerCase()
: r.kind === "user"
? a.user_id === r.user_id
: r.kind === "mailbox"
? a.address?.toLowerCase() === r.address?.toLowerCase()
: true);
setRecipients((cur) => (cur.some(same) ? cur : [...cur, r]));
};
const save = async (e: FormEvent) => {
e.preventDefault();
if (delivery === "digest" && !RX_TIME.test(digestTime)) {
setMsg({ ok: false, text: "Give the digest time as HH:MM." });
return;
}
let p: number | null = null;
if (rule.param) {
p = Number(param);
if (!param.trim() || !Number.isInteger(p) || p < 1) {
setMsg({ ok: false, text: `Give a whole number of ${rule.param.name}, 1 or more.` });
return;
}
}
const body: RulePut = {
enabled,
delivery,
digest_time: digestTime,
param: p,
recipients: recipients.map((r) => {
if (r.kind === "group") return { kind: r.kind, group_dn: r.group_dn ?? "", group_name: r.group_name ?? "" };
if (r.kind === "user") return { kind: r.kind, user_id: r.user_id ?? "" };
if (r.kind === "mailbox") return { kind: r.kind, address: r.address ?? "" };
return { kind: r.kind };
}),
exclusions: rule.security ? [] : exclusions.map((x) => x.user_id),
version: rule.version,
};
setBusy(true);
setMsg(null);
try {
await admin9Api.saveRule(rule.event_type, body);
onSaved();
} catch (err) {
if (err instanceof ApiError && err.status === 409) {
onConflict();
} else {
setMsg({ ok: false, text: errorText(err) });
}
} finally {
setBusy(false);
}
};
const sendTest = async () => {
const to = testTo.trim();
if (to && !RX_ADDRESS.test(to)) {
setTestMsg({ ok: false, text: "That address does not look right." });
return;
}
setTestMsg(null);
setTest(null);
try {
setTest(await admin9Api.testSend(rule.event_type, to || undefined));
onTested();
} catch (err) {
setTestMsg({ ok: false, text: errorText(err) });
}
};
const testRecipients = (t: TestSendAnswer) =>
t.recipients.map((r) => (typeof r === "string" ? r : r.why ? `${r.address} (${r.why})` : r.address)).join(", ");
return (
<section className="panel stack" aria-labelledby="rule-h">
<div className="sec-h flush">
<h3 id="rule-h">{rule.label}</h3>
{rule.security ? <span className="pill st-bad">Security</span> : null}
<span className="grow" />
<button className="btn sm ghost" type="button" onClick={onClose}>
Close
</button>
</div>
<div className="small muted">{rule.description}</div>
<form className="stack" onSubmit={save} noValidate>
<div className="frow">
<label className="ckl">
<input type="checkbox" checked={enabled} onChange={(e) => setEnabled(e.target.checked)} />
Rule enabled
</label>
<label className="f">
Delivery
<select className="in" aria-label="Delivery" value={delivery} onChange={(e) => setDelivery(e.target.value === "digest" ? "digest" : "immediate")}>
<option value="immediate">At once</option>
<option value="digest">Daily digest</option>
</select>
</label>
<label className="f">
Digest time
<input className="in mono" type="time" value={digestTime} disabled={delivery !== "digest"} onChange={(e) => setDigestTime(e.target.value)} />
</label>
{rule.param ? (
<div className="stack tight">
<label className="f" style={{ alignSelf: "stretch" }}>
{rule.param.name === "days" ? "Raise after, days" : "Raise after, hours"}
<input className="in mono" type="number" min={1} value={param} onChange={(e) => setParam(e.target.value)} />
</label>
<span className="small muted">Default {rule.param.default}.</span>
</div>
) : null}
</div>
<h3>Recipients</h3>
{recipients.length === 0 ? <div className="callout warn flush">No recipients. Nothing is sent.</div> : null}
<ul className="stack tight" aria-label="Recipients" style={{ listStyle: "none", margin: 0, padding: 0 }}>
{recipients.map((r, i) => {
const text = recipientText(r);
return (
<li key={`${r.kind}-${r.group_dn ?? r.user_id ?? r.address ?? ""}-${i}`}>
<button
className="btn sm ghost"
type="button"
aria-label={`Remove recipient ${text}`}
onClick={() => setRecipients((cur) => cur.filter((_, j) => j !== i))}
>
Remove
</button>{" "}
{text}
</li>
);
})}
</ul>
<div className="panel stack">
<label className="f">
Add a recipient
<select className="in" aria-label="Add a recipient" value={addKind} onChange={(e) => setAddKind(e.target.value as RecipientKind)}>
{kinds.map((k) => (
<option key={k} value={k}>
{KIND_LABEL[k]}
</option>
))}
</select>
</label>
{addKind === "group" ? (
<GroupSearch onPick={(g) => addRecipient({ kind: "group", group_dn: g.dn, group_name: g.name })} />
) : null}
{addKind === "user" ? (
<UserPicker label="Find a user to add" onPick={(u) => addRecipient({ kind: "user", user_id: u.id, display_name: u.display_name })} />
) : null}
{addKind === "mailbox" ? (
<div className="frow">
<label className="f">
Mailbox address
<input className="in" type="email" value={mailbox} onChange={(e) => setMailbox(e.target.value)} />
</label>
<div className="end">
<button
className="btn sm"
type="button"
onClick={() => {
const a = mailbox.trim();
if (!RX_ADDRESS.test(a)) {
setMsg({ ok: false, text: "That address does not look right." });
return;
}
addRecipient({ kind: "mailbox", address: a });
setMailbox("");
setMsg(null);
}}
>
Add mailbox
</button>
</div>
</div>
) : null}
{addKind === "dept_supervisor" ? (
<div>
<button className="btn sm" type="button" onClick={() => addRecipient({ kind: "dept_supervisor" })}>
Add the department Supervisor group
</button>
</div>
) : null}
</div>
<h3>Exclusions</h3>
{rule.security ? (
<div className="callout flush">Security events reach everyone on the list. Nobody can be excluded or opt out.</div>
) : (
<>
{exclusions.length === 0 ? <div className="small muted">Nobody is excluded.</div> : null}
<ul className="stack tight" aria-label="Exclusions" style={{ listStyle: "none", margin: 0, padding: 0 }}>
{exclusions.map((x) => (
<li key={x.user_id}>
{x.self ? (
<span className="pill st-wait">Opted out</span>
) : (
<button
className="btn sm ghost"
type="button"
aria-label={`Remove exclusion ${x.display_name ?? x.user_id}`}
onClick={() => setExclusions((cur) => cur.filter((y) => y.user_id !== x.user_id))}
>
Remove
</button>
)}{" "}
{x.display_name ?? x.user_id}
</li>
))}
</ul>
<UserPicker
label="Find a user to exclude"
exclude={exclusions.map((x) => x.user_id)}
onPick={(u) => setExclusions((cur) => [...cur, { user_id: u.id, display_name: u.display_name, self: false }])}
/>
</>
)}
<Notice msg={msg} />
<div>
<button className="btn pri" type="submit" disabled={busy}>
Save rule
</button>
</div>
</form>
<h3>Send a test</h3>
<div className="small muted">A test goes out at once, even in dry run. Leave the address empty to send it to the saved recipients.</div>
<div className="frow">
<label className="f">
Send the test to
<input className="in" type="email" placeholder="Optional" value={testTo} onChange={(e) => setTestTo(e.target.value)} />
</label>
<div className="end">
<button className="btn" type="button" onClick={() => void sendTest()}>
Send a test
</button>
</div>
</div>
<Notice msg={testMsg} />
{test ? (
<div className={test.result === "sent" ? "callout flush" : "callout warn flush"} role="status" aria-label="Test result">
<div>
Result: {resultPill(test.result)} <span className="mono small">{test.subject}</span>
</div>
{test.recipients.length ? <div className="small">To: {testRecipients(test)}</div> : <div className="small">No recipients.</div>}
{test.error ? <div className="small">Error: {test.error}</div> : null}
</div>
) : null}
</section>
);
}
/* ---------- Sent log ---------- */
function SentLog({ rules, tick }: { rules: NotifyRule[]; tick: number }) {
const [eventType, setEventType] = useState("");
const [rows, setRows] = useState<SentRow[]>([]);
const [next, setNext] = useState<string | number | null>(null);
const [error, setError] = useState<string | null>(null);
const page = async (before: string | number | null) => {
try {
const r = await admin9Api.sent({ event_type: eventType || undefined, limit: 50, before: before ?? undefined });
setRows((cur) => (before ? [...cur, ...r.rows] : r.rows));
setNext(r.next_before);
setError(null);
} catch (err) {
setError(errorText(err));
}
};
useEffect(() => {
void page(null);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [eventType, tick]);
const label = (t: string) => (t === "digest" ? "Digest" : (rules.find((r) => r.event_type === t)?.label ?? t));
return (
<section className="sec stack" aria-labelledby="sent-h">
<div className="sec-h flush">
<h3 id="sent-h">Sent log</h3>
<label className="f">
Event filter
<select className="in" aria-label="Event filter" value={eventType} onChange={(e) => setEventType(e.target.value)}>
<option value="">All events</option>
<option value="digest">Digests</option>
{rules.map((r) => (
<option key={r.event_type} value={r.event_type}>
{r.label}
</option>
))}
</select>
</label>
</div>
{error ? <div className="callout warn flush">{error}</div> : null}
<div className="tw">
<table aria-label="Sent log">
<thead>
<tr>
<th>When</th>
<th>Event</th>
<th>Subject</th>
<th>Recipients</th>
<th>Result</th>
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.id}>
<td className="small mono nowrap">{fmtTime(r.at)}</td>
<td>
{label(r.event_type)} {r.test ? <span className="pill st-ready">Test</span> : null}
</td>
<td className="small">{r.subject}</td>
<td className="small">{r.recipients.map((x) => x.address).join(", ") || <span className="muted">None</span>}</td>
<td>
{resultPill(r.result)}
{r.error ? <div className="small muted">{r.error}</div> : null}
</td>
</tr>
))}
{rows.length === 0 ? (
<tr>
<td colSpan={5} className="muted">
Nothing sent yet.
</td>
</tr>
) : null}
</tbody>
</table>
</div>
{next ? (
<div>
<button className="btn sm" type="button" onClick={() => void page(next)}>
Load older messages
</button>
</div>
) : null}
</section>
);
}
/* ---------- The tab ---------- */
function Header({ n }: { n: NotifyAnswer }) {
return (
<div className="panel small" aria-label="Mail settings" role="group">
{n.dry_run ? (
<div className="callout warn flush">Dry run: messages are logged, not sent. A test send still goes out.</div>
) : null}
<div>
Relay <span className="mono">{n.smtp.host ?? "not set"}{n.smtp.port ? `:${n.smtp.port}` : ""}</span>, {n.smtp.starttls ? "STARTTLS" : "no STARTTLS"}, from{" "}
<span className="mono">{n.smtp.from ?? "-"}</span>. Subjects start with <span className="mono">{n.subject_prefix}</span>.
</div>
</div>
);
}
export function NotificationsTab() {
const [load, reload] = useLoad((s) => admin9Api.notifications(s));
const [open, setOpen] = useState<string | null>(null);
const [msg, setMsg] = useState<Msg>(null);
const [tick, setTick] = useState(0);
if (load.state !== "ok") return <Problem load={load} />;
const n = load.data;
const rule = n.rules.find((r) => r.event_type === open) ?? null;
return (
<div className="stack">
<Header n={n} />
<div className="tw">
<table aria-label="Notification rules">
<thead>
<tr>
<th>Event</th>
<th>Status</th>
<th>Delivery</th>
<th>Recipients</th>
<th>Last sent</th>
<th />
</tr>
</thead>
<tbody>
{n.rules.map((r) => (
<tr key={r.event_type} className={open === r.event_type ? "rowlink on" : undefined}>
<td>
{r.label} {r.security ? <span className="pill st-bad">Security</span> : null}
{r.param ? (
<div className="small muted">
After {r.param.value} {r.param.name}
</div>
) : null}
</td>
<td>{r.enabled ? <span className="pill st-ok">On</span> : <span className="pill st-filed">Off</span>}</td>
<td className="small">{deliveryText(r)}</td>
<td className="small">{r.recipients.map(recipientText).join(", ") || <span className="muted">None</span>}</td>
<td className="small">
{r.last_sent ? (
<>
{fmtTime(r.last_sent.at)} {resultPill(r.last_sent.result)}
</>
) : (
<span className="muted">Never</span>
)}
</td>
<td>
<button className="btn sm ghost" type="button" aria-label={`Edit ${r.label}`} onClick={() => setOpen(r.event_type)}>
Edit
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<Notice msg={msg} />
{rule ? (
<RuleEditor
key={`${rule.event_type}-${rule.version}`}
rule={rule}
onSaved={() => {
setMsg({ ok: true, text: `${rule.label}: saved.` });
reload();
setTick((t) => t + 1);
}}
onConflict={() => {
setMsg({ ok: false, text: `${rule.label}: someone else saved it first. Their version is loaded now.` });
reload();
}}
onTested={() => setTick((t) => t + 1)}
onClose={() => setOpen(null)}
/>
) : null}
<SentLog rules={n.rules} tick={tick} />
</div>
);
}
@@ -0,0 +1,64 @@
import { fireEvent, screen, within } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { adminBase } from "../../test/admin";
import { renderApp } from "../../test/auth";
import { callsTo, mockFetch } from "../../test/fetchMock";
const PRESENCE = {
at: "2026-10-21T09:00:00Z",
sessions: [
{
session_id: "s-1",
user_id: "u-1",
display_name: "Walt Welder",
kind: "pin",
device_name: "Weld Parts Stall 3",
ip: "10.0.0.9",
created_at: "2026-10-21T07:00:00Z",
last_seen_at: "2026-10-21T08:59:00Z",
locked: false,
screen: "stall",
packet_id: 42,
},
{
session_id: "s-2",
user_id: "u-2",
display_name: "Clara Clerk",
kind: "password",
device_name: null,
ip: "10.0.0.5",
created_at: "2026-10-21T08:00:00Z",
last_seen_at: "2026-10-21T08:30:00Z",
locked: true,
screen: null,
packet_id: null,
},
],
};
describe("Admin Who is in tab", () => {
it("lists live sessions with screen, packet and lock state, and refreshes", async () => {
const fetch = mockFetch({ ...adminBase(), "/api/admin/presence": { body: PRESENCE } });
renderApp("/admin");
fireEvent.click(await screen.findByRole("tab", { name: "Who is in" }));
expect(screen.getByRole("tab", { name: "Who is in" })).toHaveAttribute("id", "admtab-presence");
const table = await screen.findByRole("table", { name: "Who is in" });
const walt = within(table).getByText("Walt Welder").closest("tr") as HTMLElement;
expect(within(walt).getByText("PIN")).toBeInTheDocument();
expect(within(walt).getByText("Weld Parts Stall 3")).toBeInTheDocument();
expect(within(walt).getByRole("link", { name: "Packet 42" })).toHaveAttribute("href", "/packets/42");
const clara = within(table).getByText("Clara Clerk").closest("tr") as HTMLElement;
expect(within(clara).getByText("Locked")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Refresh who is in" }));
await screen.findByRole("table", { name: "Who is in" });
expect(callsTo(fetch, "GET", "/api/admin/presence").length).toBeGreaterThanOrEqual(1);
});
it("says when nobody is signed in", async () => {
mockFetch({ ...adminBase(), "/api/admin/presence": { body: { at: "2026-10-21T09:00:00Z", sessions: [] } } });
renderApp("/admin");
fireEvent.click(await screen.findByRole("tab", { name: "Who is in" }));
expect(await screen.findByText("Nobody is signed in.")).toBeInTheDocument();
});
});
@@ -0,0 +1,81 @@
// Admin: who is signed in now, and which screen each one has open (phase 9).
import { useEffect } from "react";
import { Link } from "react-router";
import { admin9Api } from "../../api/admin";
import { fmtAgo, fmtTime } from "../../lib/format";
import { useLoad } from "../../lib/useLoad";
import { Problem } from "./common";
const REFRESH_MS = 30_000;
export function PresenceTab() {
const [load, reload] = useLoad((s) => admin9Api.presence(s));
useEffect(() => {
const t = window.setInterval(reload, REFRESH_MS);
return () => window.clearInterval(t);
}, [reload]);
const rows = load.state === "ok" ? load.data.sessions : [];
return (
<div className="stack">
<div className="sec-h">
<span className="small muted">
Live sessions seen within the idle time.{load.state === "ok" ? ` As of ${fmtTime(load.data.at)}.` : ""} Refreshes every 30 seconds.
</span>
<span className="grow" />
<button className="btn sm ghost" type="button" onClick={reload}>
Refresh who is in
</button>
</div>
<Problem load={load} />
{load.state === "ok" ? (
<div className="tw">
<table aria-label="Who is in">
<thead>
<tr>
<th>Name</th>
<th>Sign-in</th>
<th>Device</th>
<th>Address</th>
<th>Screen</th>
<th>Started</th>
<th>Last seen</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{rows.map((s) => (
<tr key={s.session_id}>
<td>{s.display_name}</td>
<td>{s.kind === "pin" ? "PIN" : "Password"}</td>
<td>{s.device_name ?? <span className="muted">-</span>}</td>
<td className="mono small">{s.ip ?? "-"}</td>
<td className="small">
{s.screen ?? <span className="muted">-</span>}
{s.packet_id ? (
<>
{" "}
<Link to={`/packets/${s.packet_id}`}>Packet {s.packet_id}</Link>
</>
) : null}
</td>
<td className="small mono nowrap">{fmtTime(s.created_at)}</td>
<td className="small mono nowrap">{fmtAgo(s.last_seen_at)}</td>
<td>{s.locked ? <span className="pill st-wait">Locked</span> : <span className="pill st-ok">Active</span>}</td>
</tr>
))}
{rows.length === 0 ? (
<tr>
<td colSpan={8} className="muted">
Nobody is signed in.
</td>
</tr>
) : null}
</tbody>
</table>
</div>
) : null}
</div>
);
}
@@ -0,0 +1,200 @@
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { ADMIN_DEPTS, adminBase } from "../../test/admin";
import { renderApp } from "../../test/auth";
import { callsTo, mockFetch, type MockRequest } from "../../test/fetchMock";
const USERS = [
{
id: "u-1",
sam_account: "weld.walt",
display_name: "Walt Welder",
emp_no: "03001",
email: null,
active: true,
roles: ["shop:03:worker"],
pin: { set: true, disabled: false },
flags: { emp_missing: false, emp_duplicate: false, disabled: false },
last_signin_at: null,
ad_synced_at: null,
welder_stamp: "W12",
profile_active: true,
station_limits: { "03": [31] },
directory_flags: {},
},
{
id: "u-2",
sam_account: "old.olga",
display_name: "Olga Old",
emp_no: "03002",
email: null,
active: true,
roles: ["shop:03:worker"],
pin: { set: false, disabled: false },
flags: { emp_missing: false, emp_duplicate: false, disabled: false },
last_signin_at: null,
ad_synced_at: null,
welder_stamp: null,
profile_active: false,
station_limits: {},
directory_flags: {},
},
];
const FLAGS = {
checked_at: "2026-10-21T01:31:00Z",
missing: [{ user_id: "u-9", sam_account: "noemp.nora", display_name: "Nora NoEmp" }],
duplicate: [
{
emp_no: "9001",
users: [
{ user_id: "u-7", sam_account: "dup.dave", display_name: "Dave Dup" },
{ user_id: "u-8", sam_account: "dup.dora", display_name: "Dora Dup" },
],
},
],
};
function routes(extra: Record<string, unknown> = {}) {
return {
...adminBase(),
"/api/admin/users": { body: USERS },
"/api/admin/departments": { body: ADMIN_DEPTS },
"/api/admin/directory-flags": { body: FLAGS },
...extra,
} as Parameters<typeof mockFetch>[0];
}
async function openTab() {
renderApp("/admin");
fireEvent.click(await screen.findByRole("tab", { name: "Profiles" }));
return screen.findByRole("table", { name: "Profiles" });
}
describe("Admin Profiles tab", () => {
it("lists profiles with stamp and limits, and shows the directory flags", async () => {
mockFetch(routes());
const table = await openTab();
const walt = within(table).getByText("Walt Welder").closest("tr") as HTMLElement;
expect(within(walt).getByText("W12")).toBeInTheDocument();
expect(await within(walt).findByText("03:Stall 1")).toBeInTheDocument();
const olga = within(table).getByText("Olga Old").closest("tr") as HTMLElement;
expect(within(olga).getByText("Inactive")).toBeInTheDocument();
expect(within(olga).getByText("No limit")).toBeInTheDocument();
const missing = await screen.findByRole("list", { name: "Users with no employee ID" });
expect(within(missing).getByText("Nora NoEmp")).toBeInTheDocument();
const dup = screen.getByRole("list", { name: "Duplicate employee IDs" });
expect(dup).toHaveTextContent("9001: Dave Dup (dup.dave), Dora Dup (dup.dora)");
});
it("saves only what changed in a profile", async () => {
const fetch = mockFetch(routes({ "PATCH /api/admin/users/u-1/profile": { body: USERS[0] } }));
await openTab();
fireEvent.click(screen.getByRole("button", { name: "Edit profile of Walt Welder" }));
const form = await screen.findByRole("form", { name: "Profile of Walt Welder" });
fireEvent.change(within(form).getByLabelText("Welder stamp"), { target: { value: "W 12" } });
fireEvent.click(within(form).getByRole("button", { name: "Save profile" }));
expect(await within(form).findByRole("alert")).toHaveTextContent("1 to 12 letters, digits or dashes");
fireEvent.change(within(form).getByLabelText("Welder stamp"), { target: { value: "W-12" } });
fireEvent.click(within(form).getByLabelText("03 Stall 2"));
fireEvent.click(within(form).getByLabelText("Profile active"));
expect(within(form).queryByLabelText("03 Old Stall")).not.toBeInTheDocument();
fireEvent.click(within(form).getByRole("button", { name: "Save profile" }));
expect(await within(form).findByText("Profile saved.")).toBeInTheDocument();
expect(callsTo(fetch, "PATCH", "/api/admin/users/u-1/profile")).toEqual([
{ welder_stamp: "W-12", profile_active: false, station_limits: { "03": [31, 32] } },
]);
});
it("previews a CSV with errors and keeps Apply off", async () => {
const fetch = mockFetch(
routes({
"POST /api/admin/users/import": {
body: {
rows: [
{ line: 2, employee_id: "03001", user_id: "u-1", display_name: "Walt Welder", changes: { welder_stamp: { from: "W12", to: "W13" } }, errors: [] },
{ line: 3, employee_id: "99999", user_id: null, display_name: null, changes: {}, errors: ["unknown employee ID"] },
],
ok: 1,
errors: 1,
applied: false,
},
},
}),
);
await openTab();
const apply = screen.getByRole("button", { name: "Apply import" });
expect(apply).toBeDisabled();
fireEvent.change(screen.getByLabelText("CSV text"), { target: { value: "employee_id,welder_stamp\n03001,W13\n99999,X1\n" } });
fireEvent.click(screen.getByRole("button", { name: "Preview import" }));
const table = await screen.findByRole("table", { name: "Import preview" });
expect(within(table).getByText("welder stamp: W12 to W13")).toBeInTheDocument();
expect(within(table).getByText("unknown employee ID")).toBeInTheDocument();
expect(screen.getByText("1 row ready, 1 with errors.")).toBeInTheDocument();
expect(apply).toBeDisabled();
expect(callsTo(fetch, "POST", "/api/admin/users/import")).toEqual([{ csv: "employee_id,welder_stamp\n03001,W13\n99999,X1\n", apply: false }]);
});
it("applies a clean preview, and needs a new preview after an edit", async () => {
const clean = {
rows: [{ line: 2, employee_id: "03002", user_id: "u-2", display_name: "Olga Old", changes: { active: { from: false, to: true } }, errors: [] }],
ok: 1,
errors: 0,
applied: false,
};
const fetch = mockFetch(
routes({
"POST /api/admin/users/import": (req: MockRequest) => ({ body: { ...clean, applied: (req.body as { apply: boolean }).apply } }),
}),
);
await openTab();
const csv = "employee_id,active\n03002,yes\n";
fireEvent.change(screen.getByLabelText("CSV text"), { target: { value: csv } });
fireEvent.click(screen.getByRole("button", { name: "Preview import" }));
expect(await screen.findByText("active: no to yes")).toBeInTheDocument();
const apply = screen.getByRole("button", { name: "Apply import" });
await waitFor(() => expect(apply).toBeEnabled());
fireEvent.change(screen.getByLabelText("CSV text"), { target: { value: csv + "\n" } });
expect(apply).toBeDisabled();
expect(screen.getByText(/Preview it again/)).toBeInTheDocument();
fireEvent.change(screen.getByLabelText("CSV text"), { target: { value: csv } });
await waitFor(() => expect(apply).toBeEnabled());
fireEvent.click(apply);
expect(await screen.findByText("Applied. 1 user updated.")).toBeInTheDocument();
expect(callsTo(fetch, "POST", "/api/admin/users/import")).toEqual([
{ csv, apply: false },
{ csv, apply: true },
]);
});
it("shows the rows of a 422 on apply and changes nothing", async () => {
let calls = 0;
mockFetch(
routes({
"POST /api/admin/users/import": () => {
calls += 1;
const body = {
rows: [{ line: 2, employee_id: "03001", user_id: "u-1", display_name: "Walt Welder", changes: {}, errors: calls > 1 ? ["stamp taken"] : [] }],
ok: calls > 1 ? 0 : 1,
errors: calls > 1 ? 1 : 0,
applied: false,
};
return calls > 1 ? { status: 422, body } : { body };
},
}),
);
await openTab();
fireEvent.change(screen.getByLabelText("CSV text"), { target: { value: "employee_id,welder_stamp\n03001,W99\n" } });
fireEvent.click(screen.getByRole("button", { name: "Preview import" }));
const apply = screen.getByRole("button", { name: "Apply import" });
await waitFor(() => expect(apply).toBeEnabled());
fireEvent.click(apply);
expect(await screen.findByText("stamp taken")).toBeInTheDocument();
expect(screen.getByRole("alert")).toHaveTextContent("Nothing was applied.");
expect(apply).toBeDisabled();
});
});
+386
View File
@@ -0,0 +1,386 @@
// Admin: the Packet Station profile of each user (welder stamp, station limits, active), the CSV import, and the
// directory flags for missing and duplicate employee IDs (phase 9).
import { useEffect, useState, type ChangeEvent, type FormEvent } from "react";
import { admin9Api, type AdminDepartment, type ImportAnswer, type ProfileUser } from "../../api/admin";
import { ApiError } from "../../api/client";
import { errorText, fmtTime } from "../../lib/format";
import { useLoad } from "../../lib/useLoad";
import { Notice, Problem, type Msg } from "./common";
const RX_STAMP = /^[A-Za-z0-9-]{1,12}$/;
function show(v: unknown): string {
if (v === null || v === undefined || v === "") return "none";
if (Array.isArray(v)) return v.length ? v.join("; ") : "none";
if (typeof v === "boolean") return v ? "yes" : "no";
if (typeof v === "object") return JSON.stringify(v);
return String(v);
}
/* ---------- Directory flags ---------- */
export function DirectoryFlagsPanel() {
const [load] = useLoad((s) => admin9Api.directoryFlags(s));
if (load.state !== "ok") return <Problem load={load} />;
const f = load.data;
const clean = f.missing.length === 0 && f.duplicate.length === 0;
return (
<section className="panel stack" aria-labelledby="dirflags-h">
<div className="sec-h flush">
<h3 id="dirflags-h">Directory flags</h3>
<span className="ct">{f.checked_at ? `From the reconcile ${fmtTime(f.checked_at)}` : "Not checked yet"}</span>
</div>
{clean ? <div className="small muted">Every active user has a unique employee ID in AD.</div> : null}
{f.missing.length ? (
<div>
<b>No employee ID in AD:</b>
<ul aria-label="Users with no employee ID" className="chips plain">
{f.missing.map((u) => (
<li key={u.user_id} className="chip static">
{u.display_name} <span className="mono small muted">{u.sam_account}</span>
</li>
))}
</ul>
</div>
) : null}
{f.duplicate.length ? (
<div>
<b>Employee IDs on more than one account:</b>
<ul aria-label="Duplicate employee IDs" className="stack tight" style={{ listStyle: "none", margin: 0, padding: 0 }}>
{f.duplicate.map((d) => (
<li key={d.emp_no}>
<span className="mono">{d.emp_no}</span>: {d.users.map((u) => `${u.display_name} (${u.sam_account})`).join(", ")}
</li>
))}
</ul>
</div>
) : null}
{!clean ? <div className="small muted">Ask IT to fix the employeeID attribute in AD. The CSV import skips these users.</div> : null}
</section>
);
}
/* ---------- One user's profile ---------- */
function ProfileEditor({ u, depts, onSaved, onClose }: { u: ProfileUser; depts: AdminDepartment[]; onSaved: () => void; onClose: () => void }) {
const [stamp, setStamp] = useState(u.welder_stamp ?? "");
const [active, setActive] = useState(u.profile_active ?? true);
const [limits, setLimits] = useState<Record<string, number[]>>(u.station_limits ?? {});
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState<Msg>(null);
const shop = depts.filter((d) => d.kind === "shop" && d.stations.some((s) => s.active));
const toggle = (code: string, id: number, on: boolean) =>
setLimits((cur) => {
const list = cur[code] ?? [];
return { ...cur, [code]: on ? [...list.filter((x) => x !== id), id] : list.filter((x) => x !== id) };
});
const save = async (e: FormEvent) => {
e.preventDefault();
const s = stamp.trim();
if (s && !RX_STAMP.test(s)) {
setMsg({ ok: false, text: "A welder stamp is 1 to 12 letters, digits or dashes." });
return;
}
const before = u.station_limits ?? {};
const changed: Record<string, number[]> = {};
for (const code of new Set([...Object.keys(before), ...Object.keys(limits)])) {
const a = [...(before[code] ?? [])].sort((x, y) => x - y).join(",");
const b = [...(limits[code] ?? [])].sort((x, y) => x - y);
if (a !== b.join(",")) changed[code] = b;
}
const patch: { welder_stamp?: string | null; profile_active?: boolean; station_limits?: Record<string, number[]> } = {};
if ((u.welder_stamp ?? "") !== s) patch.welder_stamp = s || null;
if ((u.profile_active ?? true) !== active) patch.profile_active = active;
if (Object.keys(changed).length) patch.station_limits = changed;
if (!Object.keys(patch).length) {
setMsg({ ok: true, text: "Nothing changed." });
return;
}
setBusy(true);
setMsg(null);
try {
await admin9Api.patchProfile(u.id, patch);
setMsg({ ok: true, text: "Profile saved." });
onSaved();
} catch (err) {
setMsg({ ok: false, text: errorText(err) });
} finally {
setBusy(false);
}
};
return (
<form className="panel stack" onSubmit={save} noValidate aria-labelledby="prof-h">
<div className="sec-h">
<h3 id="prof-h">Profile of {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>
<div className="frow">
<label className="f">
Welder stamp
<input className="in mono" value={stamp} maxLength={12} onChange={(e) => setStamp(e.target.value)} />
</label>
<label className="ckl">
<input type="checkbox" checked={active} onChange={(e) => setActive(e.target.checked)} />
Profile active
</label>
</div>
<div className="small muted">An inactive profile is left out of the welder picker and the worker filters. Roles still come from AD.</div>
<h3>Station limits</h3>
<div className="small muted">No station ticked in a department means no limit there.</div>
{shop.map((d) => (
<fieldset key={d.code} className="panel">
<legend className="small">
{d.code} {d.name}
</legend>
<div className="chips">
{d.stations
.filter((s) => s.active)
.map((s) => (
<label key={s.id} className="ckl">
<input
type="checkbox"
aria-label={`${d.code} ${s.name}`}
checked={(limits[d.code] ?? []).includes(s.id)}
onChange={(e) => toggle(d.code, s.id, e.target.checked)}
/>
{s.name}
</label>
))}
</div>
</fieldset>
))}
<Notice msg={msg} />
<div>
<button className="btn pri" type="submit" disabled={busy}>
Save profile
</button>
</div>
</form>
);
}
/* ---------- CSV import ---------- */
function CsvImport({ onApplied }: { onApplied: () => void }) {
const [csv, setCsv] = useState("");
const [preview, setPreview] = useState<ImportAnswer | null>(null);
const [previewed, setPreviewed] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState<Msg>(null);
const onFile = async (e: ChangeEvent<HTMLInputElement>) => {
const f = e.target.files?.[0];
if (!f) return;
if (f.size > 1024 * 1024) {
setMsg({ ok: false, text: "The file is over 1 MB." });
return;
}
setCsv(await f.text());
setPreview(null);
setMsg(null);
};
const run = async (apply: boolean) => {
if (!csv.trim()) {
setMsg({ ok: false, text: "Paste the CSV or choose a file first." });
return;
}
setBusy(true);
setMsg(null);
try {
const r = await admin9Api.importProfiles(csv, apply);
setPreview(r);
setPreviewed(csv);
if (apply && r.applied) {
const n = r.rows.filter((row) => Object.keys(row.changes).length > 0).length;
setMsg({ ok: true, text: n === 1 ? "Applied. 1 user updated." : `Applied. ${n} users updated.` });
onApplied();
}
} catch (err) {
if (err instanceof ApiError && err.status === 422 && err.body && typeof err.body === "object" && "rows" in err.body) {
setPreview(err.body as ImportAnswer);
setPreviewed(csv);
setMsg({ ok: false, text: "Nothing was applied. Fix the errors and preview again." });
} else {
setMsg({ ok: false, text: errorText(err) });
}
} finally {
setBusy(false);
}
};
const fresh = preview !== null && previewed === csv;
const canApply = fresh && !preview.applied && preview.errors === 0 && preview.rows.length > 0;
return (
<section className="panel stack" aria-labelledby="csv-h">
<h3 id="csv-h">Import profiles from CSV</h3>
<div className="small muted">
Columns: <span className="mono">employee_id</span> (required), <span className="mono">welder_stamp</span>, <span className="mono">active</span> (yes or no),{" "}
<span className="mono">stations</span> (<span className="mono">03:Stall 1;03:Stall 2</span>). An empty cell leaves a value as it is. A dash clears it.
</div>
<label className="f">
CSV file
<input className="in" type="file" accept=".csv,text/csv" onChange={(e) => void onFile(e)} />
</label>
<label className="f">
CSV text
<textarea className="in mono" rows={6} aria-label="CSV text" value={csv} onChange={(e) => setCsv(e.target.value)} />
</label>
<div className="chips">
<button className="btn" type="button" disabled={busy} onClick={() => void run(false)}>
Preview import
</button>
<button className="btn pri" type="button" disabled={busy || !canApply} onClick={() => void run(true)}>
Apply import
</button>
</div>
<Notice msg={msg} />
{preview && !fresh ? <div className="small muted">The CSV changed. Preview it again before you apply.</div> : null}
{preview ? (
<>
<div className="small" role="status">
{preview.ok} {preview.ok === 1 ? "row" : "rows"} ready, {preview.errors} with errors.
{preview.applied ? " Applied." : ""}
</div>
<div className="tw">
<table aria-label="Import preview">
<thead>
<tr>
<th>Line</th>
<th>Employee ID</th>
<th>User</th>
<th>Changes</th>
<th>Errors</th>
</tr>
</thead>
<tbody>
{preview.rows.map((r) => (
<tr key={r.line}>
<td className="mono small">{r.line}</td>
<td className="mono">{r.employee_id}</td>
<td>{r.display_name ?? <span className="muted">-</span>}</td>
<td className="small">
{Object.keys(r.changes).length ? (
Object.entries(r.changes).map(([field, c]) => (
<div key={field}>
{field.replace(/_/g, " ")}: {show(c.from)} to {show(c.to)}
</div>
))
) : (
<span className="muted">No change</span>
)}
</td>
<td className="small">
{r.errors.length ? (
r.errors.map((e) => (
<div key={e} className="st-bad">
{e}
</div>
))
) : (
<span className="muted">-</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</>
) : null}
</section>
);
}
/* ---------- The tab ---------- */
function stationNames(u: ProfileUser, depts: AdminDepartment[]): string {
const out: string[] = [];
for (const [code, ids] of Object.entries(u.station_limits ?? {})) {
const d = depts.find((x) => x.code === code);
for (const id of ids) out.push(`${code}:${d?.stations.find((s) => s.id === id)?.name ?? id}`);
}
return out.join(", ");
}
export function ProfilesTab() {
const [q, setQ] = useState("");
const [query, setQuery] = useState("");
const [selected, setSelected] = useState<string | null>(null);
const [load, reload] = useLoad((s) => admin9Api.users(query, s), query);
const [depts] = useLoad((s) => admin9Api.departments(s));
useEffect(() => {
const t = window.setTimeout(() => setQuery(q.trim()), 250);
return () => window.clearTimeout(t);
}, [q]);
const rows = load.state === "ok" ? load.data : [];
const deptList = depts.state === "ok" ? depts.data : [];
const current = rows.find((u) => u.id === selected) ?? null;
return (
<div className="stack">
<DirectoryFlagsPanel />
<form className="frow" role="search" onSubmit={(e) => e.preventDefault()}>
<label className="f">
Search profiles
<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 aria-label="Profiles">
<thead>
<tr>
<th>Name</th>
<th>Employee ID</th>
<th>Welder stamp</th>
<th>Station limits</th>
<th>Profile</th>
<th />
</tr>
</thead>
<tbody>
{rows.map((u) => (
<tr key={u.id} className={selected === u.id ? "rowlink on" : undefined}>
<td>
{u.display_name} <span className="mono small muted">{u.sam_account}</span>
</td>
<td className="mono">{u.emp_no ?? <span className="muted">Missing</span>}</td>
<td className="mono">{u.welder_stamp ?? <span className="muted">-</span>}</td>
<td className="small">{stationNames(u, deptList) || <span className="muted">No limit</span>}</td>
<td>{u.profile_active === false ? <span className="pill st-filed">Inactive</span> : <span className="pill st-ok">Active</span>}</td>
<td>
<button className="btn sm ghost" type="button" aria-label={`Edit profile of ${u.display_name}`} onClick={() => setSelected(u.id)}>
Edit
</button>
</td>
</tr>
))}
{rows.length === 0 ? (
<tr>
<td colSpan={6} className="muted">
No users match.
</td>
</tr>
) : null}
</tbody>
</table>
</div>
) : null}
{current ? <ProfileEditor key={current.id} u={current} depts={deptList} onSaved={reload} onClose={() => setSelected(null)} /> : null}
<CsvImport onApplied={reload} />
</div>
);
}
@@ -0,0 +1,98 @@
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { adminBase } from "../../test/admin";
import { renderApp } from "../../test/auth";
import { callsTo, mockFetch } from "../../test/fetchMock";
const SETTINGS = {
values: { session_idle_minutes: 15, signin_account_limit: 5, signin_burst_threshold: 20 },
defaults: { session_idle_minutes: 15, signin_account_limit: 5, signin_burst_threshold: 20 },
bounds: { session_idle_minutes: [1, 60], signin_account_limit: [1, 15], signin_burst_threshold: [5, 200] },
version: 2,
};
const ALERTS = [
{ at: "2026-10-21T09:00:00Z", kind: "lockout", user_id: "u-1", display_name: "Walt Welder", account: "weld.walt", ip: "10.0.0.9", detail: { minutes: 10 } },
{ at: "2026-10-21T08:00:00Z", kind: "admin_change", user_id: "u-0", display_name: "Ann Admin", account: "admin.ann", ip: "10.0.0.2", detail: { action: "admin.profile_updated" } },
];
const INTEGRITY = {
run: { id: 9, started_at: "2026-10-21T04:00:00Z", finished_at: "2026-10-21T04:02:00Z", status: "ok", counts: { seals: 120, assets: 40 } },
findings: [{ kind: "asset_hash", subject: "asset 77", detail: { expected: "ab", got: "cd" }, at: "2026-10-21T04:01:00Z" }],
};
function routes(extra: Parameters<typeof mockFetch>[0] = {}) {
return {
...adminBase(),
"GET /api/admin/security/settings": { body: SETTINGS },
"/api/admin/security/alerts": { body: ALERTS },
"/api/admin/security/integrity": { body: INTEGRITY },
...extra,
};
}
async function openTab() {
renderApp("/admin");
fireEvent.click(await screen.findByRole("tab", { name: "Security" }));
return screen.findByLabelText("Lock after idle, minutes");
}
describe("Admin Security tab", () => {
it("shows each setting with its default and bounds, checks bounds, and saves only changes", async () => {
const fetch = mockFetch(
routes({
"PUT /api/admin/security/settings": { body: { ...SETTINGS, values: { ...SETTINGS.values, signin_account_limit: 4 }, version: 3 } },
}),
);
const idle = await openTab();
expect(idle).toHaveValue(15);
expect(idle).toHaveAccessibleDescription("Default 15. Allowed 1 to 60.");
const limit = screen.getByLabelText("Wrong passwords per account before a cooldown");
fireEvent.change(limit, { target: { value: "20" } });
fireEvent.click(screen.getByRole("button", { name: "Save security settings" }));
expect(await screen.findByRole("alert")).toHaveTextContent("from 1 to 15");
expect(callsTo(fetch, "PUT", "/api/admin/security/settings")).toHaveLength(0);
fireEvent.change(limit, { target: { value: "4" } });
fireEvent.click(screen.getByRole("button", { name: "Save security settings" }));
expect(await screen.findByText(/Security settings saved/)).toBeInTheDocument();
expect(callsTo(fetch, "PUT", "/api/admin/security/settings")).toEqual([{ values: { signin_account_limit: 4 }, version: 2 }]);
});
it("reloads after a 409", async () => {
const fetch = mockFetch(routes({ "PUT /api/admin/security/settings": { status: 409, body: { detail: "Changed", code: "version_conflict" } } }));
await openTab();
fireEvent.change(screen.getByLabelText("Lock after idle, minutes"), { target: { value: "20" } });
fireEvent.click(screen.getByRole("button", { name: "Save security settings" }));
await waitFor(() => expect(callsTo(fetch, "GET", "/api/admin/security/settings")).toHaveLength(2));
expect(await screen.findByRole("alert")).toHaveTextContent("Someone else saved the settings first.");
});
it("lists alerts with kind filters and a period, and shows the integrity result", async () => {
const fetch = mockFetch(routes());
await openTab();
const table = await screen.findByRole("table", { name: "Security alerts" });
expect(within(table).getByText("Account cooldowns")).toBeInTheDocument();
expect(within(table).getByText("Admin changes")).toBeInTheDocument();
const kinds = screen.getByRole("group", { name: "Alert kinds" });
fireEvent.click(within(kinds).getByRole("button", { name: /Admin changes/ }));
expect(within(kinds).getByRole("button", { name: /Admin changes/ })).toHaveAttribute("aria-pressed", "false");
expect(within(table).queryByText("Admin changes")).not.toBeInTheDocument();
expect(within(table).getByText("Walt Welder")).toBeInTheDocument();
fireEvent.change(screen.getByRole("combobox", { name: /Period/ }), { target: { value: "168" } });
await waitFor(() => expect(fetch.mock.calls.some(([u]) => String(u) === "/api/admin/security/alerts?hours=168")).toBe(true));
expect(fetch.mock.calls.some(([u]) => String(u) === "/api/admin/security/alerts?hours=24")).toBe(true);
const findings = await screen.findByRole("table", { name: "Integrity findings" });
expect(within(findings).getByText("asset 77")).toBeInTheDocument();
expect(screen.getByText("1 finding")).toHaveClass("st-bad");
expect(screen.getByText("seals: 120, assets: 40")).toBeInTheDocument();
});
it("says when the integrity check has not run", async () => {
mockFetch(routes({ "/api/admin/security/integrity": { body: { run: null, findings: [] } } }));
await openTab();
expect(await screen.findByText(/has not run yet/)).toBeInTheDocument();
});
});
+268
View File
@@ -0,0 +1,268 @@
// Admin: the security settings, the alerts of the last day and the latest integrity check (phase 9).
import { useEffect, useState, type FormEvent } from "react";
import { admin9Api, type SecuritySettingsAnswer } from "../../api/admin";
import { ApiError } from "../../api/client";
import { errorText, fmtDetail, fmtTime } from "../../lib/format";
import { useLoad } from "../../lib/useLoad";
import { Notice, Problem, type Msg } from "./common";
/** Labels for the known keys. A key the server adds later shows by its name. */
const LABELS: Record<string, string> = {
session_idle_minutes: "Lock after idle, minutes",
session_lock_minutes: "Sign out after locked, minutes",
session_desk_minutes: "Desk idle sign-out, minutes",
session_password_hours: "Password session, hours at most",
session_pin_hours: "PIN session, hours at most",
roles_refresh_minutes: "Re-read AD roles every, minutes",
signin_account_limit: "Wrong passwords per account before a cooldown",
signin_account_max_failures: "Wrong passwords per account before a cooldown",
signin_account_window_minutes: "Account failure window, minutes",
signin_account_cooldown_minutes: "Account cooldown, minutes",
signin_ip_limit: "Failures per address before a block",
signin_ip_max_failures: "Failures per address before a block",
signin_ip_window_minutes: "Address failure window, minutes",
signin_burst_threshold: "Failed sign-ins across accounts in 5 minutes that raise an alert",
};
const labelFor = (k: string) => LABELS[k] ?? k.replace(/_/g, " ");
function SettingsForm() {
const [load, reload] = useLoad((s) => admin9Api.securitySettings(s));
const [base, setBase] = useState<SecuritySettingsAnswer | null>(null);
const [vals, setVals] = useState<Record<string, string>>({});
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState<Msg>(null);
useEffect(() => {
if (load.state !== "ok") return;
setBase(load.data);
setVals(Object.fromEntries(Object.entries(load.data.values).map(([k, v]) => [k, String(v)])));
}, [load]);
if (!base) return <Problem load={load} />;
const keys = Object.keys(base.bounds).length ? Object.keys(base.bounds) : Object.keys(base.values);
const save = async (e: FormEvent) => {
e.preventDefault();
const changed: Record<string, number> = {};
for (const k of keys) {
const raw = (vals[k] ?? "").trim();
const n = Number(raw);
const [lo, hi] = base.bounds[k] ?? [Number.NEGATIVE_INFINITY, Number.POSITIVE_INFINITY];
if (!raw || !Number.isInteger(n) || n < lo || n > hi) {
setMsg({ ok: false, text: `${labelFor(k)}: give a whole number from ${lo} to ${hi}.` });
return;
}
if (n !== base.values[k]) changed[k] = n;
}
if (!Object.keys(changed).length) {
setMsg({ ok: true, text: "Nothing changed." });
return;
}
setBusy(true);
setMsg(null);
try {
const r = await admin9Api.saveSecuritySettings(changed, base.version);
setBase(r);
setVals(Object.fromEntries(Object.entries(r.values).map(([k, v]) => [k, String(v)])));
setMsg({ ok: true, text: "Security settings saved. They apply within a minute." });
} catch (err) {
if (err instanceof ApiError && err.status === 409) {
setMsg({ ok: false, text: "Someone else saved the settings first. Their values are loaded now." });
setBase(null);
reload();
} else {
setMsg({ ok: false, text: errorText(err) });
}
} finally {
setBusy(false);
}
};
return (
<form className="panel stack" onSubmit={save} noValidate aria-labelledby="secset-h">
<h3 id="secset-h">Settings</h3>
<div className="frow">
{keys.map((k) => {
const b = base.bounds[k];
const def = base.defaults[k];
return (
<div key={k} className="stack tight">
<label className="f" style={{ alignSelf: "stretch" }}>
{labelFor(k)}
<input
className="in mono"
type="number"
inputMode="numeric"
min={b?.[0]}
max={b?.[1]}
value={vals[k] ?? ""}
aria-describedby={`sec-${k}-hint`}
onChange={(e) => setVals((cur) => ({ ...cur, [k]: e.target.value }))}
/>
</label>
<span id={`sec-${k}-hint`} className="small muted">
Default {def ?? "-"}.{b ? ` Allowed ${b[0]} to ${b[1]}.` : ""}
</span>
</div>
);
})}
</div>
<Notice msg={msg} />
<div>
<button className="btn pri" type="submit" disabled={busy}>
Save security settings
</button>
</div>
</form>
);
}
const KINDS: ReadonlyArray<{ id: string; label: string }> = [
{ id: "lockout", label: "Account cooldowns" },
{ id: "pin_failures", label: "Wrong PINs" },
{ id: "stepup_wrong_pin", label: "Wrong unlock or signature" },
{ id: "denied", label: "Denied actions" },
{ id: "signin_burst", label: "Sign-in bursts" },
{ id: "admin_change", label: "Admin changes" },
{ id: "it_edit", label: "IT edits" },
{ id: "integrity", label: "Integrity" },
];
const kindLabel = (k: string) => KINDS.find((x) => x.id === k)?.label ?? k.replace(/_/g, " ");
function Alerts() {
const [hours, setHours] = useState(24);
const [load, reload] = useLoad((s) => admin9Api.securityAlerts(hours, s), String(hours));
const [off, setOff] = useState<Set<string>>(new Set());
const toggle = (k: string) =>
setOff((cur) => {
const next = new Set(cur);
if (next.has(k)) next.delete(k);
else next.add(k);
return next;
});
const rows = load.state === "ok" ? load.data.filter((a) => !off.has(a.kind)) : [];
const counts = new Map<string, number>();
if (load.state === "ok") for (const a of load.data) counts.set(a.kind, (counts.get(a.kind) ?? 0) + 1);
return (
<section className="sec stack" aria-labelledby="secalerts-h">
<div className="sec-h flush">
<h3 id="secalerts-h">Alerts</h3>
<label className="f">
Period
<select className="in" aria-label="Period" value={hours} onChange={(e) => setHours(Number(e.target.value))}>
<option value={24}>Last 24 hours</option>
<option value={72}>Last 3 days</option>
<option value={168}>Last 7 days</option>
</select>
</label>
<span className="grow" />
<button className="btn sm ghost" type="button" onClick={reload}>
Refresh alerts
</button>
</div>
<div className="chips" role="group" aria-label="Alert kinds">
{KINDS.map((k) => (
<button key={k.id} type="button" className="chip sm" aria-pressed={!off.has(k.id)} onClick={() => toggle(k.id)}>
{k.label} <span className="mono small">{counts.get(k.id) ?? 0}</span>
</button>
))}
</div>
<Problem load={load} />
{load.state === "ok" ? (
<div className="tw">
<table aria-label="Security alerts">
<thead>
<tr>
<th>When</th>
<th>Kind</th>
<th>Who</th>
<th>Address</th>
<th>Detail</th>
</tr>
</thead>
<tbody>
{rows.map((a, i) => (
<tr key={`${a.at}-${a.kind}-${i}`}>
<td className="small mono nowrap">{fmtTime(a.at)}</td>
<td>{kindLabel(a.kind)}</td>
<td>
{a.display_name ?? ""} {a.account ? <span className="mono small muted">{a.account}</span> : null}
</td>
<td className="mono small">{a.ip ?? "-"}</td>
<td className="small">{fmtDetail(a.detail)}</td>
</tr>
))}
{rows.length === 0 ? (
<tr>
<td colSpan={5} className="muted">
No alerts in this period.
</td>
</tr>
) : null}
</tbody>
</table>
</div>
) : null}
</section>
);
}
function Integrity() {
const [load] = useLoad((s) => admin9Api.integrity(s));
if (load.state !== "ok") return <Problem load={load} />;
const { run, findings } = load.data;
return (
<section className="panel stack" aria-labelledby="integ-h">
<div className="sec-h flush">
<h3 id="integ-h">Integrity check</h3>
{run ? (
<>
<span className={findings.length || run.status === "failed" ? "pill st-bad" : "pill st-ok"}>
{findings.length ? `${findings.length} ${findings.length === 1 ? "finding" : "findings"}` : run.status === "failed" ? "Failed" : "Clean"}
</span>
<span className="ct">Finished {fmtTime(run.finished_at)}</span>
</>
) : null}
</div>
{!run ? <div className="small muted">The integrity check has not run yet. It runs nightly at 04:00.</div> : null}
{run?.counts ? <div className="small">{fmtDetail(run.counts)}</div> : null}
{findings.length ? (
<div className="tw">
<table aria-label="Integrity findings">
<thead>
<tr>
<th>Kind</th>
<th>Subject</th>
<th>Detail</th>
</tr>
</thead>
<tbody>
{findings.map((f, i) => (
<tr key={`${f.kind}-${f.subject}-${i}`}>
<td className="mono small">{f.kind.replace(/_/g, " ")}</td>
<td className="mono small">{f.subject}</td>
<td className="small">{fmtDetail(f.detail)}</td>
</tr>
))}
</tbody>
</table>
</div>
) : null}
</section>
);
}
export function SecurityTab() {
return (
<div className="stack">
<SettingsForm />
<Alerts />
<Integrity />
</div>
);
}
@@ -0,0 +1,86 @@
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { adminBase } from "../../test/admin";
import { renderApp } from "../../test/auth";
import { callsTo, mockFetch } from "../../test/fetchMock";
const PRINT = {
id: 5,
at: "2026-10-20T10:00:00Z",
by_name: "Clara Clerk",
kind: "databook",
what: "Data book",
pages: 12,
packet_id: 42,
packet_label: "L662001/1",
};
async function openTab() {
renderApp("/admin");
fireEvent.click(await screen.findByRole("tab", { name: "Stamp check" }));
return screen.findByLabelText("Stamp line or code");
}
describe("Admin Stamp check tab", () => {
it("checks a typed stamp line", async () => {
const fetch = mockFetch({
...adminBase(),
"POST /api/admin/stamp-verify": { body: { found: true, valid: true, reason: null, print: PRINT } },
});
const box = await openTab();
fireEvent.click(screen.getByRole("button", { name: "Check stamp" }));
expect(await screen.findByRole("alert")).toHaveTextContent("Type or paste the stamp line first.");
fireEvent.change(box, { target: { value: " PS 0123456789abcdef " } });
fireEvent.click(screen.getByRole("button", { name: "Check stamp" }));
const result = await screen.findByRole("status", { name: "Stamp result" });
expect(result).toHaveTextContent("Genuine.");
expect(within(result).getByText("Clara Clerk")).toBeInTheDocument();
expect(within(result).getByText("L662001/1")).toBeInTheDocument();
expect(within(result).getByText("Data book, 12 pages")).toBeInTheDocument();
expect(callsTo(fetch, "POST", "/api/admin/stamp-verify")).toEqual([{ text: "PS 0123456789abcdef" }]);
});
it("says when a stamp is not found or does not verify", async () => {
let n = 0;
mockFetch({
...adminBase(),
"POST /api/admin/stamp-verify": () => {
n += 1;
return n === 1
? { body: { found: false, valid: false, reason: null, print: null } }
: { body: { found: true, valid: false, reason: "The signature does not match.", print: PRINT } };
},
});
const box = await openTab();
fireEvent.change(box, { target: { value: "ffffffffffffffff" } });
fireEvent.click(screen.getByRole("button", { name: "Check stamp" }));
expect(await screen.findByText(/Not found\./)).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Check stamp" }));
expect(await screen.findByText(/Does not verify\./)).toBeInTheDocument();
expect(screen.getByText(/The signature does not match\./)).toBeInTheDocument();
});
it("uploads a PDF and lists each stamp found", async () => {
const fetch = mockFetch({
...adminBase(),
"POST /api/admin/stamp-verify/pdf": {
body: { stamps: [{ found: true, valid: true, reason: null, print: PRINT }, { found: false, valid: false, reason: null, print: null }] },
},
});
await openTab();
const input = screen.getByLabelText("PDF of a printed copy");
fireEvent.change(input, { target: { files: [new File(["x"], "copy.txt", { type: "text/plain" })] } });
fireEvent.click(screen.getByRole("button", { name: "Check PDF" }));
expect(await screen.findByRole("alert")).toHaveTextContent("Only a PDF can be checked.");
fireEvent.change(input, { target: { files: [new File(["%PDF-1.7"], "copy.pdf", { type: "application/pdf" })] } });
fireEvent.click(screen.getByRole("button", { name: "Check PDF" }));
const results = await screen.findByRole("status", { name: "PDF stamp results" });
expect(results).toHaveTextContent("Stamp 1: Genuine.");
expect(results).toHaveTextContent("Stamp 2: Not found.");
await waitFor(() => expect(callsTo(fetch, "POST", "/api/admin/stamp-verify/pdf")).toHaveLength(1));
const init = fetch.mock.calls.find(([u]) => String(u) === "/api/admin/stamp-verify/pdf")?.[1];
expect(init?.body).toBeInstanceOf(FormData);
expect((init?.body as FormData).get("file")).toBeInstanceOf(File);
});
});
@@ -0,0 +1,151 @@
// Admin: check the stamp on a printed or saved copy against the print log (phase 9).
import { useState, type FormEvent } from "react";
import { admin9Api, type StampResult } from "../../api/admin";
import { errorText, fmtTime } from "../../lib/format";
import { Notice, type Msg } from "./common";
const PDF_MAX = 25 * 1024 * 1024;
function StampCard({ r, n }: { r: StampResult; n?: number }) {
const p = r.print;
const head = n !== undefined ? `Stamp ${n}: ` : "";
if (!r.found) {
return (
<div className="callout warn flush">
<b>{head}Not found.</b> No print in the log has this stamp.{r.reason ? ` ${r.reason}` : ""}
</div>
);
}
return (
<div className={r.valid ? "callout flush" : "callout warn flush"}>
<b>{r.valid ? `${head}Genuine.` : `${head}Does not verify.`}</b>
{!r.valid && r.reason ? ` ${r.reason}` : ""}
{p ? (
<dl className="kv" style={{ marginTop: 8 }}>
<div>
<dt>Printed</dt>
<dd className="mono small">{fmtTime(p.at)}</dd>
</div>
<div>
<dt>By</dt>
<dd>{p.by_name ?? "-"}</dd>
</div>
<div>
<dt>What</dt>
<dd>
{p.what ?? p.kind}
{p.pages ? `, ${p.pages} ${p.pages === 1 ? "page" : "pages"}` : ""}
</dd>
</div>
<div>
<dt>Packet</dt>
<dd>{p.packet_label ?? (p.packet_id ? String(p.packet_id) : "-")}</dd>
</div>
</dl>
) : null}
</div>
);
}
export function StampVerifyTab() {
const [text, setText] = useState("");
const [file, setFile] = useState<File | null>(null);
const [one, setOne] = useState<StampResult | null>(null);
const [many, setMany] = useState<StampResult[] | null>(null);
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState<Msg>(null);
const checkText = async (e: FormEvent) => {
e.preventDefault();
if (!text.trim()) {
setMsg({ ok: false, text: "Type or paste the stamp line first." });
return;
}
setBusy(true);
setMsg(null);
setOne(null);
try {
setOne(await admin9Api.stampVerify(text.trim()));
} catch (err) {
setMsg({ ok: false, text: errorText(err) });
} finally {
setBusy(false);
}
};
const checkPdf = async (e: FormEvent) => {
e.preventDefault();
if (!file) {
setMsg({ ok: false, text: "Choose a PDF first." });
return;
}
if (file.type && file.type !== "application/pdf") {
setMsg({ ok: false, text: "Only a PDF can be checked." });
return;
}
if (file.size > PDF_MAX) {
setMsg({ ok: false, text: "The PDF is over 25 MB." });
return;
}
setBusy(true);
setMsg(null);
setMany(null);
try {
setMany((await admin9Api.stampVerifyPdf(file)).stamps);
} catch (err) {
setMsg({ ok: false, text: errorText(err) });
} finally {
setBusy(false);
}
};
return (
<div className="stack">
<div className="small muted">Every printed copy carries a stamp line with a 16 character code. Check it here to see whether the copy came from Packet Station.</div>
<form className="panel stack" onSubmit={checkText} noValidate aria-labelledby="stamp-text-h">
<h3 id="stamp-text-h">Check a stamp line</h3>
<label className="f">
Stamp line or code
<textarea
className="in mono"
rows={2}
aria-label="Stamp line or code"
value={text}
onChange={(e) => setText(e.target.value)}
/>
</label>
<div>
<button className="btn pri" type="submit" disabled={busy}>
Check stamp
</button>
</div>
{one ? (
<div role="status" aria-label="Stamp result">
<StampCard r={one} />
</div>
) : null}
</form>
<form className="panel stack" onSubmit={checkPdf} noValidate aria-labelledby="stamp-pdf-h">
<h3 id="stamp-pdf-h">Check a PDF</h3>
<label className="f">
PDF of a printed copy
<input className="in" type="file" accept="application/pdf,.pdf" onChange={(e) => setFile(e.target.files?.[0] ?? null)} />
</label>
<div>
<button className="btn pri" type="submit" disabled={busy}>
Check PDF
</button>
</div>
{many ? (
<div className="stack" role="status" aria-label="PDF stamp results">
{many.length === 0 ? <div className="callout warn flush">No stamp line found in this PDF.</div> : null}
{many.map((r, i) => (
<StampCard key={i} r={r} n={i + 1} />
))}
</div>
) : null}
</form>
<Notice msg={msg} />
</div>
);
}
+83
View File
@@ -0,0 +1,83 @@
// Small pieces the phase 9 Admin tabs share.
import { useEffect, useState } from "react";
import { admin9Api, type ProfileUser } from "../../api/admin";
import { errorText } from "../../lib/format";
import type { Load } from "../../lib/useLoad";
export 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;
}
export type Msg = { ok: boolean; text: string } | null;
export function Notice({ msg }: { msg: Msg }) {
if (!msg) return null;
return (
<div className={msg.ok ? "callout flush" : "callout warn flush"} role={msg.ok ? "status" : "alert"}>
{msg.text}
</div>
);
}
/** Search the users Packet Station knows and pick one. `label` names the search box. */
export function UserPicker({ label, onPick, exclude = [] }: { label: string; onPick: (u: ProfileUser) => void; exclude?: readonly string[] }) {
const [q, setQ] = useState("");
const [rows, setRows] = useState<ProfileUser[]>([]);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const term = q.trim();
if (term.length < 2) {
setRows([]);
return;
}
const ctl = new AbortController();
const t = window.setTimeout(() => {
admin9Api
.users(term, ctl.signal)
.then((r) => {
setRows(r.slice(0, 10));
setError(null);
})
.catch((err: unknown) => {
if (!ctl.signal.aborted) setError(errorText(err));
});
}, 250);
return () => {
window.clearTimeout(t);
ctl.abort();
};
}, [q]);
const shown = rows.filter((u) => !exclude.includes(u.id));
return (
<div className="stack tight">
<label className="f">
{label}
<input className="in" type="search" placeholder="Name or account, 2 letters or more" value={q} onChange={(e) => setQ(e.target.value)} />
</label>
{error ? <div className="callout warn flush">{error}</div> : null}
{shown.length ? (
<ul className="chips plain">
{shown.map((u) => (
<li key={u.id}>
<button
type="button"
className="chip sm"
aria-label={`Pick ${u.display_name}`}
onClick={() => {
onPick(u);
setQ("");
}}
>
{u.display_name} <span className="mono small muted">{u.sam_account}</span>
</button>
</li>
))}
</ul>
) : null}
</div>
);
}
+55
View File
@@ -0,0 +1,55 @@
// Shared fixtures for the Admin tab tests.
import { DEPARTMENTS, makeMe } from "./auth";
import type { Route } from "./fetchMock";
export const ADMIN_ME = makeMe({
user: { id: "aaaaaaaa-0000-0000-0000-000000000001", display_name: "Ann Admin", sam_account: "admin.ann" },
roles: ["admin"],
screens: ["clerical", "warehouse", "supervisor", "stall", "qc", "pm", "admin"],
});
/** What every Admin render needs: the session, the departments and the Users tab it opens on. */
export function adminBase(): Record<string, Route> {
return {
"/api/auth/me": { body: ADMIN_ME },
"/api/departments": { body: DEPARTMENTS },
"/api/admin/users": { body: [] },
};
}
export const ADMIN_DEPTS = [
{
code: "03",
slug: "WeldParts",
name: "Weld Parts",
kind: "shop",
sort: 10,
active: true,
weld: true,
supervisor_group: true,
created_at: null,
groups: [
{ name: "PS-03-WeldParts-Worker", role: "shop_worker", in_ou: true },
{ name: "PS-03-WeldParts-Lead", role: "shop_lead", in_ou: true },
{ name: "PS-03-WeldParts-Supervisor", role: "shop_supervisor", in_ou: false },
],
stations: [
{ id: 31, name: "Stall 1", sort: 1, active: true },
{ id: 32, name: "Stall 2", sort: 2, active: true },
{ id: 33, name: "Old Stall", sort: 3, active: false },
],
},
{
code: "CL",
slug: "Clerical",
name: "Clerical",
kind: "support",
sort: 1,
active: true,
weld: false,
supervisor_group: false,
created_at: null,
groups: [{ name: "PS-Clerical", role: "clerical", in_ou: null }],
stations: [],
},
];