Phase 9: Admin completion, reconcile, notifications, integrity, backups
ci / frontend (pull_request) Successful in 1m11s
ci / server (pull_request) Successful in 12m2s
ci / image (pull_request) Skipped
ci / e2e (pull_request) Successful in 6m1s

Admin: Departments (name and stations; add shows the three groups IT must
create), Profiles (welder stamp, station limits, active) with a CSV import
that previews and applies only when clean, Checklists (weld and plain),
Security (session, lockout and rate-limit settings with bounds, alerts for
the last 24 hours, the integrity result), Access log with filters and a
guarded CSV, Who is in, Stamp check, Notifications, and the employeeID
flags. Every Admin change is audited.

Worker: ad-reconcile (nightly and on "refresh from AD now"), session-refresh
(15 minutes), integrity (seals, asset hashes, missing and orphan files, the
audit chain), backup-check, notify-send and notify-digest. Notifications
route per event type to AD groups, users, mailboxes or the department
Supervisor group, immediately or in a daily digest, over STARTTLS verified
against the PESCO CA, with dry run on the test stack.

Backups: scripts/backup/ps-backup.sh and ps-restore.sh with a systemd timer
at 02:00 America/Denver. Restore rehearsed on the test stack 2026-10-21.
CI: pip-audit and npm audit. pypdf 6.21.1 and certifi 2026.10.19.

Migration 0010. Closes the app part of I6, M3, O1, O2 and S3.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-21 18:27:09 -06:00
co-authored by Claude Opus 5.5
parent ce28d2f3c6
commit a82e056b6a
116 changed files with 15125 additions and 297 deletions
+409
View File
@@ -0,0 +1,409 @@
// Phase 9 Admin: departments, profiles, checklists, security, access log, presence, stamp check, directory flags,
// the AD reconcile, and notifications. Field names follow the phase 9 design contract exactly.
import type { AdminUser } from "../auth/types";
import { api } from "./client";
import { qs } from "./query";
const enc = encodeURIComponent;
/* ---------- Departments ---------- */
export interface DeptGroup {
name: string;
role: string;
/** null when AD could not be read. */
in_ou: boolean | null;
}
export interface DeptStation {
id: number;
name: string;
sort: number;
active: boolean;
}
export interface AdminDepartment {
code: string;
slug: string;
name: string;
kind: "shop" | "support";
sort: number;
active: boolean;
weld: boolean;
supervisor_group: boolean;
created_at: string | null;
groups: DeptGroup[];
stations: DeptStation[];
}
export interface DeptPatch {
name?: string;
active?: boolean;
stations?: Array<{ id?: number; name: string; sort: number; active: boolean }>;
}
export interface DeptNew {
code: string;
slug: string;
name: string;
kind: "shop" | "support";
weld: boolean;
supervisor_group: boolean;
stations: string[];
}
export interface DeptAdded {
department: AdminDepartment;
groups_to_create: string[];
}
/* ---------- Profiles ---------- */
export interface DirectoryFlags {
emp_missing?: boolean;
emp_duplicate?: boolean;
gone?: boolean;
disabled?: boolean;
}
/** A row of GET /api/admin/users with the phase 9 profile fields. */
export interface ProfileUser extends AdminUser {
welder_stamp?: string | null;
profile_active?: boolean;
station_limits?: Record<string, number[]>;
directory_flags?: DirectoryFlags | null;
}
export interface ProfilePatch {
welder_stamp?: string | null;
profile_active?: boolean;
station_limits?: Record<string, number[]>;
}
export interface ImportRow {
line: number;
employee_id: string;
user_id: string | null;
display_name: string | null;
changes: Record<string, { from: unknown; to: unknown }>;
errors: string[];
}
export interface ImportAnswer {
rows: ImportRow[];
ok: number;
errors: number;
applied: boolean;
}
/* ---------- Checklists ---------- */
export interface ChecklistItem {
group: string;
key: string;
text: string;
}
export interface ChecklistLists {
weld: ChecklistItem[];
plain: ChecklistItem[];
}
export interface ChecklistsAnswer {
value: ChecklistLists;
defaults: ChecklistLists;
version: number;
}
/* ---------- Security ---------- */
export interface SecuritySettingsAnswer {
values: Record<string, number>;
defaults: Record<string, number>;
bounds: Record<string, [number, number]>;
version: number;
}
export type AlertKind =
| "lockout"
| "pin_failures"
| "denied"
| "stepup_wrong_pin"
| "admin_change"
| "it_edit"
| "integrity"
| "signin_burst";
export interface SecurityAlert {
at: string;
kind: AlertKind | string;
user_id: string | null;
display_name: string | null;
account: string | null;
ip: string | null;
detail: unknown;
}
export interface IntegrityFinding {
kind: string;
subject: string;
detail: unknown;
at: string;
}
export interface IntegrityAnswer {
run: { id: number; started_at: string | null; finished_at: string | null; status: string; counts: Record<string, unknown> | null } | null;
findings: IntegrityFinding[];
}
/* ---------- Access log ---------- */
export interface AccessLogFilters {
since?: string;
until?: string;
user_id?: string;
q?: string;
kind?: string;
ok?: "true" | "false" | "";
ip?: string;
limit?: number;
before?: string;
}
export interface AccessLogRow {
id: string | number;
at: string;
source: "attempt" | "audit";
kind: string;
ok: boolean;
account: string | null;
user_id: string | null;
display_name: string | null;
ip: string | null;
detail: unknown;
}
export interface AccessLogAnswer {
rows: AccessLogRow[];
next_before: string | null;
}
/* ---------- Presence ---------- */
export interface PresenceSession {
session_id: string;
user_id: string;
display_name: string;
kind: "password" | "pin" | string;
device_name: string | null;
ip: string | null;
created_at: string;
last_seen_at: string | null;
locked: boolean;
screen: string | null;
packet_id: number | null;
}
export interface PresenceAnswer {
at: string;
sessions: PresenceSession[];
}
/* ---------- Stamp check ---------- */
export interface StampResult {
found: boolean;
valid: boolean;
reason: string | null;
print: {
id: number;
at: string;
by_name: string | null;
kind: string;
what: string | null;
pages: number | null;
packet_id: number | null;
packet_label: string | null;
} | null;
}
/* ---------- Directory flags and the reconcile ---------- */
export interface FlagUser {
user_id: string;
sam_account: string;
display_name: string;
}
export interface DirectoryFlagsAnswer {
checked_at: string | null;
missing: FlagUser[];
duplicate: Array<{ emp_no: string; users: FlagUser[] }>;
}
export interface RefreshRequested {
requested: boolean;
pending: number;
job: string;
}
export interface ReconcileReport {
groups_missing: string[] | number;
groups_extra: string[] | number;
users_seen: number;
created: number;
updated: number;
deactivated: number;
sessions_ended: number;
emp_missing: string[] | number;
emp_duplicate: string[] | number;
}
export interface ReconcileAnswer {
run: { id: number; started_at: string | null; finished_at: string | null; status: string } | null;
report: Partial<ReconcileReport> | null;
}
/* ---------- Notifications ---------- */
export type RecipientKind = "group" | "user" | "mailbox" | "dept_supervisor";
export interface Recipient {
id?: number;
kind: RecipientKind;
group_dn?: string | null;
group_name?: string | null;
user_id?: string | null;
display_name?: string | null;
address?: string | null;
}
export interface NotifyRule {
event_type: string;
label: string;
description: string;
security: boolean;
dept_scoped: boolean;
param: { name: "hours" | "days" | string; value: number; default: number } | null;
enabled: boolean;
delivery: "immediate" | "digest";
digest_time: string;
recipients: Recipient[];
exclusions: Array<{ user_id: string; display_name: string | null; self: boolean }>;
version: number;
last_sent: { at: string; result: string } | null;
}
export interface NotifyAnswer {
dry_run: boolean;
smtp: { host: string | null; port: number | null; from: string | null; starttls: boolean };
subject_prefix: string;
rules: NotifyRule[];
}
export interface RulePut {
enabled: boolean;
delivery: "immediate" | "digest";
digest_time: string;
param: number | null;
recipients: Array<{ kind: RecipientKind; group_dn?: string; group_name?: string; user_id?: string; address?: string }>;
exclusions: string[];
version: number;
}
export interface AdGroupHit {
dn: string;
name: string;
description: string | null;
}
export interface TestSendAnswer {
result: string;
subject: string;
recipients: Array<{ address: string; user_id?: string | null; why?: string }> | string[];
error: string | null;
sent_id: number | null;
}
export interface SentRow {
id: number;
at: string;
event_type: string;
digest: boolean;
test: boolean;
subject: string;
recipients: Array<{ address: string; user_id?: string | null; why?: string }>;
result: string;
error: string | null;
}
export interface SentAnswer {
rows: SentRow[];
/** The id to page from. */
next_before: string | number | null;
}
export interface MyNotification {
event_type: string;
label: string;
security: boolean;
delivery: "immediate" | "digest";
reaches: boolean;
why: string[];
opted_out: boolean;
can_opt_out: boolean;
}
/* ---------- Calls ---------- */
const A = "/api/admin";
export const admin9Api = {
departments: (signal?: AbortSignal) => api.get<AdminDepartment[]>(`${A}/departments`, signal),
patchDepartment: (code: string, patch: DeptPatch) => api.patch<AdminDepartment>(`${A}/departments/${enc(code)}`, patch),
addDepartment: (d: DeptNew) => api.post<DeptAdded>(`${A}/departments`, d),
users: (q: string, signal?: AbortSignal) => api.get<ProfileUser[]>(`${A}/users${qs({ q })}`, signal),
patchProfile: (id: string, patch: ProfilePatch) => api.patch<ProfileUser>(`${A}/users/${enc(id)}/profile`, patch),
importProfiles: (csv: string, apply: boolean) => api.post<ImportAnswer>(`${A}/users/import`, { csv, apply }),
checklists: (signal?: AbortSignal) => api.get<ChecklistsAnswer>(`${A}/checklists`, signal),
saveChecklists: (value: ChecklistLists, version: number) => api.put<ChecklistsAnswer>(`${A}/checklists`, { value, version }),
securitySettings: (signal?: AbortSignal) => api.get<SecuritySettingsAnswer>(`${A}/security/settings`, signal),
saveSecuritySettings: (values: Record<string, number>, version: number) =>
api.put<SecuritySettingsAnswer>(`${A}/security/settings`, { values, version }),
securityAlerts: (hours: number, signal?: AbortSignal) => api.get<SecurityAlert[]>(`${A}/security/alerts${qs({ hours })}`, signal),
integrity: (signal?: AbortSignal) => api.get<IntegrityAnswer>(`${A}/security/integrity`, signal),
accessLog: (f: AccessLogFilters, signal?: AbortSignal) =>
api.get<AccessLogAnswer>(`${A}/access-log${qs({ ...f })}`, signal),
accessLogCsvUrl: (f: AccessLogFilters) => `${A}/access-log.csv${qs({ ...f, before: undefined, limit: undefined })}`,
presence: (signal?: AbortSignal) => api.get<PresenceAnswer>(`${A}/presence`, signal),
stampVerify: (text: string) => api.post<StampResult>(`${A}/stamp-verify`, { text }),
stampVerifyPdf: (file: File) => {
const fd = new FormData();
fd.append("file", file);
return api.post<{ stamps: StampResult[] }>(`${A}/stamp-verify/pdf`, fd);
},
directoryFlags: (signal?: AbortSignal) => api.get<DirectoryFlagsAnswer>(`${A}/directory-flags`, signal),
refreshAd: () => api.post<RefreshRequested>(`${A}/ad/refresh`, {}),
reconcile: (signal?: AbortSignal) => api.get<ReconcileAnswer>(`${A}/ad/reconcile`, signal),
notifications: (signal?: AbortSignal) => api.get<NotifyAnswer>(`${A}/notifications`, signal),
saveRule: (eventType: string, body: RulePut) => api.put<NotifyRule>(`${A}/notifications/${enc(eventType)}`, body),
searchGroups: (q: string, signal?: AbortSignal) => api.get<AdGroupHit[]>(`${A}/notifications/groups${qs({ q })}`, signal),
testSend: (eventType: string, to?: string) =>
api.post<TestSendAnswer>(`${A}/notifications/${enc(eventType)}/test`, to ? { to } : {}),
sent: (f: { event_type?: string | undefined; limit?: number; before?: string | number | undefined }, signal?: AbortSignal) =>
api.get<SentAnswer>(`${A}/notifications/sent${qs(f)}`, signal),
};
export const accountNotifyApi = {
list: (signal?: AbortSignal) => api.get<MyNotification[]>("/api/account/notifications", signal),
setOptOut: (eventType: string, optOut: boolean) =>
api.put<MyNotification | null>(`/api/account/notifications/${enc(eventType)}`, { opt_out: optOut }),
};