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 }),
|
||||
};
|
||||
Reference in New Issue
Block a user