diff --git a/apps/server/src/routes/setup.ts b/apps/server/src/routes/setup.ts index 2898048..4479b87 100644 --- a/apps/server/src/routes/setup.ts +++ b/apps/server/src/routes/setup.ts @@ -32,6 +32,17 @@ interface TestBody { config: Record; } +// Config keys that hold device secrets — never sent back to the client. Covers +// the push Digest password, the rotated device web-UI login, and the Dingtian +// relay password. Centralised so /state and /assign redact consistently. +const SECRET_CONFIG_KEYS = ["pushPassword", "webPassword", "relayPassword"] as const; + +function redactSecrets(config: Record): Record { + const out = { ...config }; + for (const k of SECRET_CONFIG_KEYS) delete out[k]; + return out; +} + export async function setupRoutes(app: FastifyInstance, db: Db): Promise { registerBuiltinDrivers(); setDeviceLogSink((line) => app.log.info(line)); @@ -79,13 +90,15 @@ export async function setupRoutes(app: FastifyInstance, db: Db): Promise { }, ); - // Current setup status + assignments. + // Current setup status + assignments. Secrets are stripped from each config + // (the UI lists devices; it never needs the stored push/relay/web passwords). app.get( "/api/setup/state", { preHandler: adminGuard }, async () => { const state = await db.select().from(setupState).where(eq(setupState.id, 1)).get(); - const assignments = await db.select().from(laneDevices).all(); + const rows = await db.select().from(laneDevices).all(); + const assignments = rows.map((r) => ({ ...r, config: redactSecrets(r.config) })); return { completedAt: state?.completedAt ?? null, assignments }; }, ); @@ -215,9 +228,34 @@ export async function setupRoutes(app: FastifyInstance, db: Db): Promise { enabled: true, }; await db.insert(laneDevices).values(row); - // Don't echo device secrets back (push Digest password, web-UI login). - const { pushPassword: _pw, webPassword: _wp, ...safeConfig } = fullConfig; - return reply.code(201).send({ ...row, config: safeConfig }); + // Don't echo device secrets back (push Digest password, web-UI login, …). + return reply.code(201).send({ ...row, config: redactSecrets(fullConfig) }); + }, + ); + + // Unassign (remove) a device instance. The schema is multi-instance — one row + // per (lane, category, instance) — so removing one is just deleting its row by + // id. Lets the admin manage a LIST of devices per category (add/remove), not a + // fixed one-per-category slot. Admin-only. See wiki/concepts/first-run-setup.md. + // + // NOTE: we only drop our row; we do NOT un-harden / un-configure the device + // itself (e.g. clear the Dingtian push URL). The device keeps its last config + // harmlessly — pushes from an unknown device id are already rejected (see + // routes/devices.ts), and re-assigning reconfigures it. A future "factory + // reset on unassign" can hook here if needed. + app.delete<{ Params: { id: string } }>( + "/api/setup/assign/:id", + { preHandler: adminGuard }, + async (req, reply) => { + const existing = await db + .select() + .from(laneDevices) + .where(eq(laneDevices.id, req.params.id)) + .get(); + if (!existing) return reply.code(404).send({ error: "no such device assignment" }); + await db.delete(laneDevices).where(eq(laneDevices.id, req.params.id)); + app.log.info(`unassigned device ${req.params.id} (${existing.category}/${existing.driverId}, lane ${existing.lane})`); + return reply.code(204).send(); }, ); diff --git a/apps/web/src/SetupWizard.tsx b/apps/web/src/SetupWizard.tsx index d9ed102..eecfb29 100644 --- a/apps/web/src/SetupWizard.tsx +++ b/apps/web/src/SetupWizard.tsx @@ -1,10 +1,13 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useCallback } from "react"; import { assignDevice, discoverDevices, fetchBackendIps, fetchCatalog, + fetchState, testDevice, + unassignDevice, + type Assignment, type BackendIpCandidate, type Catalog, type CatalogEntry, @@ -13,32 +16,39 @@ import { type TestResult, } from "./api.js"; -// First-run setup wizard (scaffold). The admin picks a device per category for a -// lane from the driver catalog and fills in its connection config. Drivers that -// support LAN discovery (e.g. UHPPOTE) get a "Scan" button that lists found -// devices; selecting one auto-fills the config. Auth is via the admin's session -// cookie (the SPA only renders this for admins). See wiki/concepts/first-run-setup.md -// and device-discovery.md. +// First-run setup wizard (scaffold). The admin assigns devices per lane from the +// driver catalog. The data model is multi-instance — one lane_devices row per +// instance — so EVERY category supports more than one device: each section lists +// the already-assigned instances (with Remove) and an "Add" form. Drivers that +// support LAN discovery get a "Scan" button. Auth is via the admin's session +// cookie. See wiki/concepts/first-run-setup.md and device-discovery.md. -const CATEGORIES: { key: DeviceCategory; title: string }[] = [ - { key: "access", title: "Access controller" }, - { key: "reader", title: "Reader" }, - { key: "camera", title: "Camera (entry/exit snapshot)" }, - { key: "printer", title: "Printer" }, +const CATEGORIES: { key: DeviceCategory; title: string; noun: string }[] = [ + { key: "access", title: "Access controllers", noun: "access controller" }, + { key: "reader", title: "Readers", noun: "reader" }, + { key: "camera", title: "Cameras (entry/exit snapshot)", noun: "camera" }, + { key: "printer", title: "Printers", noun: "printer" }, ]; export function SetupWizard() { const [catalog, setCatalog] = useState(null); + const [assignments, setAssignments] = useState(null); const [lane, setLane] = useState(1); - const [picked, setPicked] = useState>>({}); const [error, setError] = useState(null); + const reloadState = useCallback(() => { + return fetchState() + .then((s) => setAssignments(s.assignments)) + .catch((e: Error) => setError(e.message)); + }, []); + useEffect(() => { fetchCatalog().then(setCatalog).catch((e: Error) => setError(e.message)); - }, []); + reloadState(); + }, [reloadState]); - if (error) return

Failed to load catalog: {error}

; - if (!catalog) return

Loading device catalog…

; + if (error) return

Failed to load setup: {error}

; + if (!catalog || !assignments) return

Loading device catalog…

; return (
@@ -54,41 +64,154 @@ export function SetupWizard() { style={{ width: "4rem" }} /> + + Devices are added per lane. Switch lanes to configure another. + - {CATEGORIES.map(({ key, title }) => ( - ( + setPicked((p) => ({ ...p, [key]: id }))} + assignments={assignments.filter((a) => a.category === key && a.lane === lane)} + onChanged={reloadState} /> ))}
); } -function CategoryPicker({ +function CategorySection({ lane, category, title, + noun, entries, discoverableIds, - selectedId, - onSelect, + assignments, + onChanged, }: { lane: number; category: DeviceCategory; title: string; + noun: string; entries: CatalogEntry[]; discoverableIds: string[]; - selectedId: string | undefined; - onSelect: (id: string) => void; + assignments: Assignment[]; + onChanged: () => Promise | void; }) { + // Show the add-form automatically when nothing is assigned yet; otherwise it's + // collapsed behind "Add another" so the list stays the focus. + const [adding, setAdding] = useState(false); + const showForm = adding || assignments.length === 0; + + return ( +
+ + {title} · lane {lane} + + + {assignments.length > 0 && ( +
    + {assignments.map((a) => ( + + ))} +
+ )} + + {showForm ? ( + { + await onChanged(); + setAdding(false); + }} + onCancel={assignments.length > 0 ? () => setAdding(false) : undefined} + /> + ) : ( + + )} +
+ ); +} + +function AssignmentRow({ + assignment, + onChanged, +}: { + assignment: Assignment; + onChanged: () => Promise | void; +}) { + const [removing, setRemoving] = useState(false); + const [error, setError] = useState(null); + + // A short, human summary of the instance: role (if any) + host. + const cfg = assignment.config; + const role = typeof cfg.role === "string" ? cfg.role : null; + const host = typeof cfg.host === "string" ? cfg.host : null; + + async function remove() { + if (!confirm(`Remove this ${assignment.driverId} device?`)) return; + setRemoving(true); + setError(null); + try { + await unassignDevice(assignment.id); + await onChanged(); + } catch (e) { + setError((e as Error).message); + setRemoving(false); + } + } + + return ( +
  • + {assignment.driverId} + {role && {role}} + {host && {host}} + {!assignment.enabled && (disabled)} + + {error && {error}} + +
  • + ); +} + +function DeviceForm({ + lane, + category, + entries, + discoverableIds, + onSaved, + onCancel, +}: { + lane: number; + category: DeviceCategory; + entries: CatalogEntry[]; + discoverableIds: string[]; + onSaved: () => Promise | void; + onCancel?: () => void; +}) { + const [selectedId, setSelectedId] = useState(""); const selected = entries.find((e) => e.id === selectedId); const canDiscover = selected != null && discoverableIds.includes(selected.id); @@ -98,7 +221,6 @@ function CategoryPicker({ const [testing, setTesting] = useState(false); const [testError, setTestError] = useState(null); const [saving, setSaving] = useState(false); - const [saved, setSaved] = useState(false); const [saveError, setSaveError] = useState(null); const [found, setFound] = useState(null); const [scanning, setScanning] = useState(false); @@ -124,8 +246,6 @@ function CategoryPicker({ .then(({ candidates }) => { if (!live) return; setBackendIps(candidates); - // Pre-fill with the on-subnet auto-pick (the first candidate, since the - // server sorts on-subnet first), unless the admin already chose one. setBackendIp((cur) => cur || candidates.find((c) => c.onDeviceSubnet)?.ip || ""); }) .catch(() => { @@ -137,6 +257,13 @@ function CategoryPicker({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [testedHost]); + function selectDriver(id: string) { + setSelectedId(id); + setConfig({}); + setFound(null); + resetStatus(); + } + async function scan() { if (!selected) return; setScanning(true); @@ -165,11 +292,10 @@ function CategoryPicker({ return out; } - // Editing config invalidates a prior test/save. + // Editing config invalidates a prior test. function resetStatus() { setTested(null); setTestError(null); - setSaved(false); setSaveError(null); } @@ -199,7 +325,8 @@ function CategoryPicker({ config: mergedConfig(), ...(backendIp ? { backendIp } : {}), }); - setSaved(true); + // Parent reloads the list; this form is unmounted or reset by it. + await onSaved(); } catch (e) { setSaveError((e as Error).message); } finally { @@ -208,12 +335,11 @@ function CategoryPicker({ } return ( -
    - {title} +
    {entries.length === 0 ? ( No drivers registered. ) : ( - selectDriver(e.target.value)}> @@ -258,16 +384,33 @@ function CategoryPicker({
    ))} @@ -277,9 +420,14 @@ function CategoryPicker({ - + {onCancel && ( + + )} {testError &&

    Test failed: {testError}

    } @@ -332,10 +480,9 @@ function CategoryPicker({ )} {saveError &&

    Save failed: {saveError}

    } - {saved &&

    Saved and configured ✓

    } )} -
    + ); } diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index 5ae0b90..71ca6f7 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -160,6 +160,32 @@ export interface AssignBody { } /** Save + configure the device (preconditions, push setup), then persist. */ -export function assignDevice(body: AssignBody): Promise<{ id: string }> { +export function assignDevice(body: AssignBody): Promise { return apiFetch("/api/setup/assign", { method: "POST", body: JSON.stringify(body) }); } + +/** A persisted device assignment (one per instance; secrets stripped). */ +export interface Assignment { + id: string; + lane: number; + category: DeviceCategory; + driverId: string; + config: DeviceConfig; + enabled: boolean; + createdAt?: string; +} + +export interface SetupState { + completedAt: string | null; + assignments: Assignment[]; +} + +/** Current setup status + all assigned device instances. */ +export function fetchState(): Promise { + return apiFetch("/api/setup/state"); +} + +/** Remove one assigned device instance by id. */ +export function unassignDevice(id: string): Promise { + return apiFetch(`/api/setup/assign/${id}`, { method: "DELETE" }); +} diff --git a/wiki/concepts/first-run-setup.md b/wiki/concepts/first-run-setup.md index 97050ac..632f1a2 100644 --- a/wiki/concepts/first-run-setup.md +++ b/wiki/concepts/first-run-setup.md @@ -27,17 +27,33 @@ each device's connection config. authenticated input push ([[device-input-flow]]) — the admin never touches the device's own web UI. **Fails the save** (no DB row) if the device can't be configured, so there are no orphan/half-configured rows. On success persists to `lane_devices`. -4. **Complete** — `POST /api/setup/complete` marks the single-row `setup_state`. +4. **Remove** — `DELETE /api/setup/assign/:id` (admin-only) drops one instance's row. Only our + row is removed; the device itself is not un-hardened/un-configured (a stale push from an + unknown device id is already rejected, and re-assigning reconfigures it). +5. **Complete** — `POST /api/setup/complete` marks the single-row `setup_state`. -## Config granularity +## Config granularity — multi-instance per category -Organized **per lane** — each lane gets an access controller, reader(s), and camera(s), each with -its own connection settings. Matches the architecture's "mixable per lane" reality (a lane can -serve permit holders via [[wiegand]] and casual via host-side reads on one relay — see -[[entry-exit-readers]]). +The data model is **multi-instance**: `lane_devices` holds **one row per instance**, keyed by a +generated `id`, with no one-per-(lane, category) constraint. So a lane can have **more than one of +every category** — e.g. two printers (an entry dispenser + a booth printer; see +[[printer-roles-failover]]), multiple readers, multiple cameras. `assign` always inserts a new row +(never an upsert), and `state` returns the full list. + +The `SetupWizard` reflects this: each category shows the **list of assigned instances** for the +current lane (with **Remove**) plus an **Add another** form — not a single fixed slot. `select`-type +config fields (e.g. a printer's role) render as dropdowns. + +Organized **per lane** — each lane gets its access controller(s), reader(s), camera(s), and +printer(s), each with its own connection settings. Matches the architecture's "mixable per lane" +reality (a lane can serve permit holders via [[wiegand]] and casual via host-side reads on one +relay — see [[entry-exit-readers]]). ## Security notes -- The assign/state/complete endpoints require the **admin** role ([[local-jwt-auth]]). +- The assign/state/delete/complete endpoints require the **admin** role ([[local-jwt-auth]]). - Device **credentials are stored in `lane_devices.config`** — protect at rest ([[disk-os-hardening]]); device hosts belong on the isolated VLAN ([[network-isolation]]). +- **Secrets are stripped on the way out**: `assign` and `state` both redact `pushPassword`, + `webPassword`, and `relayPassword` from the returned config (the UI lists devices; it never + needs the stored secrets). diff --git a/wiki/log.md b/wiki/log.md index 1a2bffd..0b40587 100644 --- a/wiki/log.md +++ b/wiki/log.md @@ -227,3 +227,14 @@ guarantee. Recorded in [[dingtian-relay]] (new Hardening section). - New page: [[printer-status-monitoring]]. Updated [[rongta-printer]], [[index]]. - Open: capture the page's actual text for an ACTIVE fault (pull paper / open cover) to confirm the Yes flip; wire degraded/offline into failover + entry-flow all-down policy. + +## [2026-06-15] ingest | Multi-instance device setup (add/remove per category) +- Confirmed the data model was already multi-instance (lane_devices = one row per instance, + assign always inserts); the limitation was UI-only (one slot per category). +- Backend: added DELETE /api/setup/assign/:id (unassign); /state now redacts secrets + (pushPassword/webPassword/relayPassword) via a shared redactSecrets() also used by /assign. +- Web: SetupWizard reworked — each category lists assigned instances (with Remove) + "Add + another" form; select-type config fields now render as dropdowns (fixes printer role input). +- Verified via Fastify inject: 2 printers assigned to one lane -> both listed, no secret leak, + delete -> 204, delete unknown -> 404, count drops to 1. Full repo typechecks (8/8). +- Updated [[first-run-setup]].