Setup: manage multiple device instances per category (add/remove)
The data model was already multi-instance (lane_devices = one row per instance; assign always inserts) -- the limitation was UI-only. Make the whole flow support more than one of every category: - Backend: add DELETE /api/setup/assign/:id (unassign by id). /state now redacts secrets (pushPassword/webPassword/relayPassword) via a shared redactSecrets() also used by /assign -- it was returning raw config rows. - Web: SetupWizard reworked from one fixed slot per category into a list of assigned instances (driver/role/host + Remove) plus an "Add another" form. select-type config fields (e.g. printer role) now render as dropdowns. - api.ts: add fetchState(), unassignDevice(), Assignment/SetupState types. Verified via Fastify inject: two printers assigned to one lane both list, no secret leak, delete -> 204, delete unknown -> 404, count drops to 1. Full repo typechecks. Wiki: first-run-setup documents multi-instance + delete + redaction.
This commit is contained in:
@@ -32,6 +32,17 @@ interface TestBody {
|
|||||||
config: Record<string, string | number | boolean>;
|
config: Record<string, string | number | boolean>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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<string, unknown>): Record<string, unknown> {
|
||||||
|
const out = { ...config };
|
||||||
|
for (const k of SECRET_CONFIG_KEYS) delete out[k];
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
export async function setupRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
export async function setupRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||||
registerBuiltinDrivers();
|
registerBuiltinDrivers();
|
||||||
setDeviceLogSink((line) => app.log.info(line));
|
setDeviceLogSink((line) => app.log.info(line));
|
||||||
@@ -79,13 +90,15 @@ export async function setupRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// 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(
|
app.get(
|
||||||
"/api/setup/state",
|
"/api/setup/state",
|
||||||
{ preHandler: adminGuard },
|
{ preHandler: adminGuard },
|
||||||
async () => {
|
async () => {
|
||||||
const state = await db.select().from(setupState).where(eq(setupState.id, 1)).get();
|
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 };
|
return { completedAt: state?.completedAt ?? null, assignments };
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -215,9 +228,34 @@ export async function setupRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
};
|
};
|
||||||
await db.insert(laneDevices).values(row);
|
await db.insert(laneDevices).values(row);
|
||||||
// Don't echo device secrets back (push Digest password, web-UI login).
|
// 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: redactSecrets(fullConfig) });
|
||||||
return reply.code(201).send({ ...row, config: safeConfig });
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
+195
-48
@@ -1,10 +1,13 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect, useCallback } from "react";
|
||||||
import {
|
import {
|
||||||
assignDevice,
|
assignDevice,
|
||||||
discoverDevices,
|
discoverDevices,
|
||||||
fetchBackendIps,
|
fetchBackendIps,
|
||||||
fetchCatalog,
|
fetchCatalog,
|
||||||
|
fetchState,
|
||||||
testDevice,
|
testDevice,
|
||||||
|
unassignDevice,
|
||||||
|
type Assignment,
|
||||||
type BackendIpCandidate,
|
type BackendIpCandidate,
|
||||||
type Catalog,
|
type Catalog,
|
||||||
type CatalogEntry,
|
type CatalogEntry,
|
||||||
@@ -13,32 +16,39 @@ import {
|
|||||||
type TestResult,
|
type TestResult,
|
||||||
} from "./api.js";
|
} from "./api.js";
|
||||||
|
|
||||||
// First-run setup wizard (scaffold). The admin picks a device per category for a
|
// First-run setup wizard (scaffold). The admin assigns devices per lane from the
|
||||||
// lane from the driver catalog and fills in its connection config. Drivers that
|
// driver catalog. The data model is multi-instance — one lane_devices row per
|
||||||
// support LAN discovery (e.g. UHPPOTE) get a "Scan" button that lists found
|
// instance — so EVERY category supports more than one device: each section lists
|
||||||
// devices; selecting one auto-fills the config. Auth is via the admin's session
|
// the already-assigned instances (with Remove) and an "Add" form. Drivers that
|
||||||
// cookie (the SPA only renders this for admins). See wiki/concepts/first-run-setup.md
|
// support LAN discovery get a "Scan" button. Auth is via the admin's session
|
||||||
// and device-discovery.md.
|
// cookie. See wiki/concepts/first-run-setup.md and device-discovery.md.
|
||||||
|
|
||||||
const CATEGORIES: { key: DeviceCategory; title: string }[] = [
|
const CATEGORIES: { key: DeviceCategory; title: string; noun: string }[] = [
|
||||||
{ key: "access", title: "Access controller" },
|
{ key: "access", title: "Access controllers", noun: "access controller" },
|
||||||
{ key: "reader", title: "Reader" },
|
{ key: "reader", title: "Readers", noun: "reader" },
|
||||||
{ key: "camera", title: "Camera (entry/exit snapshot)" },
|
{ key: "camera", title: "Cameras (entry/exit snapshot)", noun: "camera" },
|
||||||
{ key: "printer", title: "Printer" },
|
{ key: "printer", title: "Printers", noun: "printer" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function SetupWizard() {
|
export function SetupWizard() {
|
||||||
const [catalog, setCatalog] = useState<Catalog | null>(null);
|
const [catalog, setCatalog] = useState<Catalog | null>(null);
|
||||||
|
const [assignments, setAssignments] = useState<Assignment[] | null>(null);
|
||||||
const [lane, setLane] = useState(1);
|
const [lane, setLane] = useState(1);
|
||||||
const [picked, setPicked] = useState<Partial<Record<DeviceCategory, string>>>({});
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const reloadState = useCallback(() => {
|
||||||
|
return fetchState()
|
||||||
|
.then((s) => setAssignments(s.assignments))
|
||||||
|
.catch((e: Error) => setError(e.message));
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchCatalog().then(setCatalog).catch((e: Error) => setError(e.message));
|
fetchCatalog().then(setCatalog).catch((e: Error) => setError(e.message));
|
||||||
}, []);
|
reloadState();
|
||||||
|
}, [reloadState]);
|
||||||
|
|
||||||
if (error) return <p style={{ color: "crimson" }}>Failed to load catalog: {error}</p>;
|
if (error) return <p style={{ color: "crimson" }}>Failed to load setup: {error}</p>;
|
||||||
if (!catalog) return <p>Loading device catalog…</p>;
|
if (!catalog || !assignments) return <p>Loading device catalog…</p>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section>
|
<section>
|
||||||
@@ -54,41 +64,154 @@ export function SetupWizard() {
|
|||||||
style={{ width: "4rem" }}
|
style={{ width: "4rem" }}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
<span style={{ color: "#666", fontSize: "0.85em" }}>
|
||||||
|
Devices are added per lane. Switch lanes to configure another.
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{CATEGORIES.map(({ key, title }) => (
|
{CATEGORIES.map(({ key, title, noun }) => (
|
||||||
<CategoryPicker
|
<CategorySection
|
||||||
key={key}
|
key={key}
|
||||||
lane={lane}
|
lane={lane}
|
||||||
category={key}
|
category={key}
|
||||||
title={title}
|
title={title}
|
||||||
|
noun={noun}
|
||||||
entries={catalog[key]}
|
entries={catalog[key]}
|
||||||
discoverableIds={catalog.discoverable}
|
discoverableIds={catalog.discoverable}
|
||||||
selectedId={picked[key]}
|
assignments={assignments.filter((a) => a.category === key && a.lane === lane)}
|
||||||
onSelect={(id) => setPicked((p) => ({ ...p, [key]: id }))}
|
onChanged={reloadState}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CategoryPicker({
|
function CategorySection({
|
||||||
lane,
|
lane,
|
||||||
category,
|
category,
|
||||||
title,
|
title,
|
||||||
|
noun,
|
||||||
entries,
|
entries,
|
||||||
discoverableIds,
|
discoverableIds,
|
||||||
selectedId,
|
assignments,
|
||||||
onSelect,
|
onChanged,
|
||||||
}: {
|
}: {
|
||||||
lane: number;
|
lane: number;
|
||||||
category: DeviceCategory;
|
category: DeviceCategory;
|
||||||
title: string;
|
title: string;
|
||||||
|
noun: string;
|
||||||
entries: CatalogEntry[];
|
entries: CatalogEntry[];
|
||||||
discoverableIds: string[];
|
discoverableIds: string[];
|
||||||
selectedId: string | undefined;
|
assignments: Assignment[];
|
||||||
onSelect: (id: string) => void;
|
onChanged: () => Promise<void> | 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 (
|
||||||
|
<fieldset style={{ marginTop: "1rem" }}>
|
||||||
|
<legend>
|
||||||
|
{title} <span style={{ color: "#888", fontWeight: 400 }}>· lane {lane}</span>
|
||||||
|
</legend>
|
||||||
|
|
||||||
|
{assignments.length > 0 && (
|
||||||
|
<ul style={{ listStyle: "none", padding: 0, margin: "0 0 0.75rem" }}>
|
||||||
|
{assignments.map((a) => (
|
||||||
|
<AssignmentRow key={a.id} assignment={a} onChanged={onChanged} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showForm ? (
|
||||||
|
<DeviceForm
|
||||||
|
lane={lane}
|
||||||
|
category={category}
|
||||||
|
entries={entries}
|
||||||
|
discoverableIds={discoverableIds}
|
||||||
|
onSaved={async () => {
|
||||||
|
await onChanged();
|
||||||
|
setAdding(false);
|
||||||
|
}}
|
||||||
|
onCancel={assignments.length > 0 ? () => setAdding(false) : undefined}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<button type="button" onClick={() => setAdding(true)}>
|
||||||
|
+ Add another {noun}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</fieldset>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AssignmentRow({
|
||||||
|
assignment,
|
||||||
|
onChanged,
|
||||||
|
}: {
|
||||||
|
assignment: Assignment;
|
||||||
|
onChanged: () => Promise<void> | void;
|
||||||
|
}) {
|
||||||
|
const [removing, setRemoving] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(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 (
|
||||||
|
<li
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.5rem",
|
||||||
|
padding: "0.4rem 0.5rem",
|
||||||
|
borderBottom: "1px solid #eee",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<strong>{assignment.driverId}</strong>
|
||||||
|
{role && <span style={{ color: "#0369a1" }}>{role}</span>}
|
||||||
|
{host && <span style={{ color: "#666" }}>{host}</span>}
|
||||||
|
{!assignment.enabled && <span style={{ color: "#b45309" }}>(disabled)</span>}
|
||||||
|
<span style={{ flex: 1 }} />
|
||||||
|
{error && <span style={{ color: "crimson" }}>{error}</span>}
|
||||||
|
<button type="button" onClick={remove} disabled={removing}>
|
||||||
|
{removing ? "Removing…" : "Remove"}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DeviceForm({
|
||||||
|
lane,
|
||||||
|
category,
|
||||||
|
entries,
|
||||||
|
discoverableIds,
|
||||||
|
onSaved,
|
||||||
|
onCancel,
|
||||||
|
}: {
|
||||||
|
lane: number;
|
||||||
|
category: DeviceCategory;
|
||||||
|
entries: CatalogEntry[];
|
||||||
|
discoverableIds: string[];
|
||||||
|
onSaved: () => Promise<void> | void;
|
||||||
|
onCancel?: () => void;
|
||||||
|
}) {
|
||||||
|
const [selectedId, setSelectedId] = useState<string>("");
|
||||||
const selected = entries.find((e) => e.id === selectedId);
|
const selected = entries.find((e) => e.id === selectedId);
|
||||||
const canDiscover = selected != null && discoverableIds.includes(selected.id);
|
const canDiscover = selected != null && discoverableIds.includes(selected.id);
|
||||||
|
|
||||||
@@ -98,7 +221,6 @@ function CategoryPicker({
|
|||||||
const [testing, setTesting] = useState(false);
|
const [testing, setTesting] = useState(false);
|
||||||
const [testError, setTestError] = useState<string | null>(null);
|
const [testError, setTestError] = useState<string | null>(null);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [saved, setSaved] = useState(false);
|
|
||||||
const [saveError, setSaveError] = useState<string | null>(null);
|
const [saveError, setSaveError] = useState<string | null>(null);
|
||||||
const [found, setFound] = useState<DiscoveredDevice[] | null>(null);
|
const [found, setFound] = useState<DiscoveredDevice[] | null>(null);
|
||||||
const [scanning, setScanning] = useState(false);
|
const [scanning, setScanning] = useState(false);
|
||||||
@@ -124,8 +246,6 @@ function CategoryPicker({
|
|||||||
.then(({ candidates }) => {
|
.then(({ candidates }) => {
|
||||||
if (!live) return;
|
if (!live) return;
|
||||||
setBackendIps(candidates);
|
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 || "");
|
setBackendIp((cur) => cur || candidates.find((c) => c.onDeviceSubnet)?.ip || "");
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
@@ -137,6 +257,13 @@ function CategoryPicker({
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [testedHost]);
|
}, [testedHost]);
|
||||||
|
|
||||||
|
function selectDriver(id: string) {
|
||||||
|
setSelectedId(id);
|
||||||
|
setConfig({});
|
||||||
|
setFound(null);
|
||||||
|
resetStatus();
|
||||||
|
}
|
||||||
|
|
||||||
async function scan() {
|
async function scan() {
|
||||||
if (!selected) return;
|
if (!selected) return;
|
||||||
setScanning(true);
|
setScanning(true);
|
||||||
@@ -165,11 +292,10 @@ function CategoryPicker({
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Editing config invalidates a prior test/save.
|
// Editing config invalidates a prior test.
|
||||||
function resetStatus() {
|
function resetStatus() {
|
||||||
setTested(null);
|
setTested(null);
|
||||||
setTestError(null);
|
setTestError(null);
|
||||||
setSaved(false);
|
|
||||||
setSaveError(null);
|
setSaveError(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,7 +325,8 @@ function CategoryPicker({
|
|||||||
config: mergedConfig(),
|
config: mergedConfig(),
|
||||||
...(backendIp ? { backendIp } : {}),
|
...(backendIp ? { backendIp } : {}),
|
||||||
});
|
});
|
||||||
setSaved(true);
|
// Parent reloads the list; this form is unmounted or reset by it.
|
||||||
|
await onSaved();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setSaveError((e as Error).message);
|
setSaveError((e as Error).message);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -208,12 +335,11 @@ function CategoryPicker({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<fieldset style={{ marginTop: "1rem" }}>
|
<div style={{ padding: "0.5rem", background: "#fafafa", borderRadius: 6 }}>
|
||||||
<legend>{title}</legend>
|
|
||||||
{entries.length === 0 ? (
|
{entries.length === 0 ? (
|
||||||
<em>No drivers registered.</em>
|
<em>No drivers registered.</em>
|
||||||
) : (
|
) : (
|
||||||
<select value={selectedId ?? ""} onChange={(e) => onSelect(e.target.value)}>
|
<select value={selectedId} onChange={(e) => selectDriver(e.target.value)}>
|
||||||
<option value="" disabled>
|
<option value="" disabled>
|
||||||
Choose a device…
|
Choose a device…
|
||||||
</option>
|
</option>
|
||||||
@@ -258,16 +384,33 @@ function CategoryPicker({
|
|||||||
<label>
|
<label>
|
||||||
{f.label}
|
{f.label}
|
||||||
{f.required ? " *" : ""}{" "}
|
{f.required ? " *" : ""}{" "}
|
||||||
<input
|
{f.type === "select" ? (
|
||||||
type={f.type === "secret" ? "password" : f.type === "number" || f.type === "port" ? "number" : "text"}
|
<select
|
||||||
value={config[f.key] ?? (f.default as string | number | undefined) ?? ""}
|
value={String(config[f.key] ?? (f.default as string | number | undefined) ?? "")}
|
||||||
placeholder={f.help}
|
onChange={(e) => {
|
||||||
onChange={(e) => {
|
const v = e.target.value;
|
||||||
const v = e.target.value;
|
setConfig((c) => ({ ...c, [f.key]: v }));
|
||||||
setConfig((c) => ({ ...c, [f.key]: v }));
|
resetStatus();
|
||||||
resetStatus();
|
}}
|
||||||
}}
|
>
|
||||||
/>
|
{f.options?.map((o) => (
|
||||||
|
<option key={o.value} value={o.value}>
|
||||||
|
{o.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
type={f.type === "secret" ? "password" : f.type === "number" || f.type === "port" ? "number" : "text"}
|
||||||
|
value={config[f.key] ?? (f.default as string | number | undefined) ?? ""}
|
||||||
|
placeholder={f.help}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = e.target.value;
|
||||||
|
setConfig((c) => ({ ...c, [f.key]: v }));
|
||||||
|
resetStatus();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -277,9 +420,14 @@ function CategoryPicker({
|
|||||||
<button type="button" onClick={test} disabled={testing}>
|
<button type="button" onClick={test} disabled={testing}>
|
||||||
{testing ? "Testing…" : "Test connection"}
|
{testing ? "Testing…" : "Test connection"}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" onClick={save} disabled={saving || saved}>
|
<button type="button" onClick={save} disabled={saving}>
|
||||||
{saving ? "Saving…" : saved ? "Saved ✓" : "Save & configure"}
|
{saving ? "Saving…" : "Save & configure"}
|
||||||
</button>
|
</button>
|
||||||
|
{onCancel && (
|
||||||
|
<button type="button" onClick={onCancel} disabled={saving}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{testError && <p style={{ color: "crimson", margin: "0.5rem 0 0" }}>Test failed: {testError}</p>}
|
{testError && <p style={{ color: "crimson", margin: "0.5rem 0 0" }}>Test failed: {testError}</p>}
|
||||||
@@ -332,10 +480,9 @@ function CategoryPicker({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{saveError && <p style={{ color: "crimson", margin: "0.5rem 0 0" }}>Save failed: {saveError}</p>}
|
{saveError && <p style={{ color: "crimson", margin: "0.5rem 0 0" }}>Save failed: {saveError}</p>}
|
||||||
{saved && <p style={{ color: "#16a34a", margin: "0.5rem 0 0" }}>Saved and configured ✓</p>}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</fieldset>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+27
-1
@@ -160,6 +160,32 @@ export interface AssignBody {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Save + configure the device (preconditions, push setup), then persist. */
|
/** Save + configure the device (preconditions, push setup), then persist. */
|
||||||
export function assignDevice(body: AssignBody): Promise<{ id: string }> {
|
export function assignDevice(body: AssignBody): Promise<Assignment> {
|
||||||
return apiFetch("/api/setup/assign", { method: "POST", body: JSON.stringify(body) });
|
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<SetupState> {
|
||||||
|
return apiFetch<SetupState>("/api/setup/state");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove one assigned device instance by id. */
|
||||||
|
export function unassignDevice(id: string): Promise<void> {
|
||||||
|
return apiFetch(`/api/setup/assign/${id}`, { method: "DELETE" });
|
||||||
|
}
|
||||||
|
|||||||
@@ -27,17 +27,33 @@ each device's connection config.
|
|||||||
authenticated input push ([[device-input-flow]]) — the admin never touches the device's own web
|
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
|
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`.
|
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
|
The data model is **multi-instance**: `lane_devices` holds **one row per instance**, keyed by a
|
||||||
its own connection settings. Matches the architecture's "mixable per lane" reality (a lane can
|
generated `id`, with no one-per-(lane, category) constraint. So a lane can have **more than one of
|
||||||
serve permit holders via [[wiegand]] and casual via host-side reads on one relay — see
|
every category** — e.g. two printers (an entry dispenser + a booth printer; see
|
||||||
[[entry-exit-readers]]).
|
[[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
|
## 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
|
- Device **credentials are stored in `lane_devices.config`** — protect at rest
|
||||||
([[disk-os-hardening]]); device hosts belong on the isolated VLAN ([[network-isolation]]).
|
([[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).
|
||||||
|
|||||||
+11
@@ -227,3 +227,14 @@ guarantee. Recorded in [[dingtian-relay]] (new Hardening section).
|
|||||||
- New page: [[printer-status-monitoring]]. Updated [[rongta-printer]], [[index]].
|
- 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
|
- 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.
|
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]].
|
||||||
|
|||||||
Reference in New Issue
Block a user