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 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> {
|
||||
registerBuiltinDrivers();
|
||||
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(
|
||||
"/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<void> {
|
||||
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();
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user