Phase 9: Admin completion, reconcile, notifications, integrity, backups
Admin: Departments (name and stations; add shows the three groups IT must create), Profiles (welder stamp, station limits, active) with a CSV import that previews and applies only when clean, Checklists (weld and plain), Security (session, lockout and rate-limit settings with bounds, alerts for the last 24 hours, the integrity result), Access log with filters and a guarded CSV, Who is in, Stamp check, Notifications, and the employeeID flags. Every Admin change is audited. Worker: ad-reconcile (nightly and on "refresh from AD now"), session-refresh (15 minutes), integrity (seals, asset hashes, missing and orphan files, the audit chain), backup-check, notify-send and notify-digest. Notifications route per event type to AD groups, users, mailboxes or the department Supervisor group, immediately or in a daily digest, over STARTTLS verified against the PESCO CA, with dry run on the test stack. Backups: scripts/backup/ps-backup.sh and ps-restore.sh with a systemd timer at 02:00 America/Denver. Restore rehearsed on the test stack 2026-10-21. CI: pip-audit and npm audit. pypdf 6.21.1 and certifi 2026.10.19. Migration 0010. Closes the app part of I6, M3, O1, O2 and S3. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,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 }),
|
||||
};
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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: [],
|
||||
},
|
||||
];
|
||||
Reference in New Issue
Block a user