diff --git a/apps/server/.env.example b/apps/server/.env.example index 6ea3b2a..854e1d7 100644 --- a/apps/server/.env.example +++ b/apps/server/.env.example @@ -8,6 +8,13 @@ # Generate one with: openssl rand -hex 32 JWT_SECRET= +# Dedicated HMAC key for signing the append-only event ledger (>=16 chars). +# Generate with: openssl rand -hex 32 +# If unset, the server falls back to JWT_SECRET (logged as a warning) — fine for +# dev, but set a dedicated key before production. Events store the key that signed +# them (keyId), so verifyChain still validates a chain that spans a key change. +EVENT_SIGNING_KEY= + # Optional ---------------------------------------------------------------- # PORT=3000 # HOST=0.0.0.0 # interface to bind. 127.0.0.1 = loopback only. diff --git a/apps/server/src/entry-flow.ts b/apps/server/src/entry-flow.ts index a51509a..6dc69ac 100644 --- a/apps/server/src/entry-flow.ts +++ b/apps/server/src/entry-flow.ts @@ -1,5 +1,5 @@ -import { randomUUID } from "node:crypto"; -import { sessions, type Db, type DeviceRow } from "@parking/db"; +import { randomInt } from "node:crypto"; +import { eq, sessions, siteConfig, type Db, type DeviceRow } from "@parking/db"; import { NoPrinterAvailableError, printWithFailover, @@ -8,6 +8,7 @@ import { type PrinterDevice, type PrinterInstance, type TicketData, + type TicketHeader, } from "@parking/devices"; import type { FastifyBaseLogger } from "fastify"; import type { DeviceInputEvent } from "./device-events.js"; @@ -91,7 +92,7 @@ export class EntryFlow { const printers = this.#loadPrinters(); // 1. PRINT FIRST. The ticket is the transient's session key — no ticket, no entry. - const ticket: TicketData = { ticketId, issuedAt }; + const ticket: TicketData = { ticketId, issuedAt, header: this.#ticketHeader() }; try { const printedBy = await printWithFailover(printers, "entry-dispenser", (d: PrinterDevice) => d.printTicket(ticket), @@ -182,9 +183,67 @@ export class EntryFlow { } return out; } + + /** Park identity for the ticket header, from site_config (all fields optional; + * the driver prints only what's set). See wiki/concepts/site-metadata.md. */ + #ticketHeader(): TicketHeader | undefined { + const row = this.#db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get(); + if (!row) return undefined; + return { + parkName: row.parkName, + operatorName: row.operatorName, + nius: row.nius, + address: row.address, + phone: row.phone, + }; + } } -/** Opaque, unguessable transient ticket id (wiki/concepts/ticket-encoding.md). */ +/** + * Opaque, unguessable transient ticket id (wiki/concepts/ticket-encoding.md). + * + * Format: 13 digits = 12 cryptographically-random digits + 1 trailing Luhn check + * digit. All-numeric so the booth can read it on ANY legacy 1D barcode scanner and + * an operator can hand-key it if every reader is down. RANDOM (not sequential): the + * id must stay unguessable so an attacker can't iterate to claim a cheaper session + * — the anti-fraud property the wiki settles. 12 random digits = 10^12 space, so + * collisions are negligible at lot scale; the unique constraints on + * ledger_events.index / sessions.id are the backstop. The Luhn digit lets a manual + * entry reject a typo (validateTicketCode) instead of failing as "session not found". + */ function newTicketId(): string { - return `T-${randomUUID()}`; + let body = ""; + for (let i = 0; i < 12; i += 1) body += String(randomInt(10)); + return body + luhnCheckDigit(body); +} + +/** The Luhn (mod-10) check digit for an all-digit string. */ +function luhnCheckDigit(digits: string): string { + let sum = 0; + // Walk right-to-left; the check digit sits at position 0 from the right, so the + // last body digit is an "even" position that gets doubled. + let double = true; + for (let i = digits.length - 1; i >= 0; i -= 1) { + let d = digits.charCodeAt(i) - 48; + if (double) { + d *= 2; + if (d > 9) d -= 9; + } + sum += d; + double = !double; + } + return String((10 - (sum % 10)) % 10); +} + +/** + * True if `code` is a well-formed ticket code: all digits and a valid Luhn checksum. + * Lets a manual-entry path (operator types the code off the ticket when readers are + * down) reject a typo up front. A scanned/looked-up id that predates this format + * (e.g. legacy `T-`) won't pass — callers should only gate MANUAL entry on it, + * never reject an id that already exists in the ledger. See ticket-encoding.md. + */ +export function validateTicketCode(code: string): boolean { + if (!/^\d{13}$/.test(code)) return false; + const body = code.slice(0, 12); + return luhnCheckDigit(body) === code[12]; } diff --git a/apps/server/src/event-log.ts b/apps/server/src/event-log.ts index 3d0eda0..9f74522 100644 --- a/apps/server/src/event-log.ts +++ b/apps/server/src/event-log.ts @@ -81,15 +81,24 @@ export function hashEvent(canonical: string): string { return createHash("sha256").update(canonical, "utf8").digest("hex"); } +/** Resolve a verifier for an event's stored `keyId` (see signer.buildVerifier). + * Returns undefined when the key that signed an event is not available. */ +export type SignerResolver = (keyId: string) => Signer | undefined; + export class EventLog { readonly #db: Db; readonly #signer: Signer; + /** Picks the verifying signer per event keyId; lets a chain span key rotations + * (JWT-fallback → dedicated key → ATECC608). Defaults to the append signer for + * callers that don't pass one (single-key chains, tests). */ + readonly #resolveVerifier: SignerResolver; /** Serialize appends: each waits for the previous to finish. */ #tail: Promise = Promise.resolve(); - constructor(db: Db, signer: Signer) { + constructor(db: Db, signer: Signer, resolveVerifier?: SignerResolver) { this.#db = db; this.#signer = signer; + this.#resolveVerifier = resolveVerifier ?? (() => signer); } /** Append one event to the chain. Returns the persisted row. Serialized. */ @@ -146,7 +155,13 @@ export class EventLog { * Walk the chain oldest→newest and recompute hashes + signatures. Returns the * first detected break, or { ok: true }. This is what reconciliation and an * integrity self-check call. Catches: tampered content, reordering, a deleted - * row (index gap), and a forged/invalid signature. + * row (index gap), a forged/invalid signature, and an event signed under a key + * that is no longer configured. + * + * Each row is verified against the signer for ITS OWN `keyId`, not the current + * append signer — so a chain that spans a key rotation (e.g. early events under + * the JWT_SECRET fallback, later ones under a dedicated EVENT_SIGNING_KEY) still + * verifies end to end. See signer.buildVerifier. */ verifyChain(): { ok: true } | { ok: false; index: number; reason: string } { const rows = this.#db.select().from(ledgerEvents).orderBy(ledgerEvents.index).all(); @@ -159,8 +174,16 @@ export class EventLog { if ((row.prevHash ?? null) !== prevHash) { return { ok: false, index: row.index, reason: "prevHash does not match chain" }; } + const verifier = this.#resolveVerifier(row.keyId); + if (!verifier) { + return { + ok: false, + index: row.index, + reason: `no signer for keyId "${row.keyId}" (key not configured)`, + }; + } const canonical = canonicalize(row); - if (!this.#signer.verify(canonical, row.signature)) { + if (!verifier.verify(canonical, row.signature)) { return { ok: false, index: row.index, reason: "signature invalid (content tampered or wrong key)" }; } prevHash = hashEvent(canonical); diff --git a/apps/server/src/routes/setup.ts b/apps/server/src/routes/setup.ts index 48563d7..11958b8 100644 --- a/apps/server/src/routes/setup.ts +++ b/apps/server/src/routes/setup.ts @@ -51,6 +51,127 @@ function redactSecrets(config: Record): Record return out; } +/** Result of the device configure pipeline: a ready-to-persist config, or an + * HTTP error to send back. Shared by assign (create) and patch (edit). */ +type ConfigureOutcome = + | { config: Record; warnings: string[] } + | { error: { code: number; message: string } }; + +/** + * Validate + configure a device, returning the config to persist. Runs the same + * pipeline for both create and edit: validate the driver config, fix + * preconditions, harden (relay password + protocol lockdown), and set up input + * push (Digest creds + push URLs). Each step is a device write (the device + * reboots on apply). The caller owns the DB row; this never touches the DB. + * + * `id` is the assignment id (stable across an edit) — it's baked into the push + * URL, so editing in place keeps the device pushing to the same path. + * `existingConfig` carries forward secrets the client never sees on edit + * (push/relay passwords), so a PATCH that omits them doesn't wipe them. + */ +async function configureDevice( + app: FastifyInstance, + args: { + id: string; + driverId: string; + config: DeviceConfig; + backendIp?: string; + existingConfig?: Record; + }, +): Promise { + const { id, driverId, config, backendIp, existingConfig } = args; + + // Start from any machine-only secrets already on the row (push/relay passwords + // are redacted out of the client's copy, so an edit would otherwise drop them), + // then layer the submitted config on top. + const fullConfig: Record = { ...existingConfig, ...config }; + // The web password the admin typed is a DESIRED value, not a stored fact: + // it's passed to the driver (via create(config) below) as the rotation + // target, but we do NOT persist it from the form. Only harden()'s VERIFIED + // secrets.webPassword gets saved — otherwise a failed rotation would leave + // the DB claiming a password the device never accepted (login stays old). + delete fullConfig.webPassword; + // webPasswordCurrent is an input-only credential (the OLD password used to + // authorize the change) — never persist it as typed. + delete fullConfig.webPasswordCurrent; + // Residual-risk warnings from device hardening (shown to the admin; the + // save still succeeds — these are "configured, but note X" advisories). + const hardenWarnings: string[] = []; + + let device; + try { + device = registry.create(driverId, config); // validates required fields + } catch (err) { + return { error: { code: 400, message: (err as Error).message } }; + } + + // Configure the device on save (before persisting, so we don't store a row + // for a device we couldn't configure): + // 1. fix preconditions (e.g. disable input_link_relay so a button press + // doesn't auto-fire its relay — host must decide first), + // 2. harden (relay password + disable unused protocol channels), and + // 3. set up input push (Digest creds + push URLs). + // Each step is a device config write (the device reboots on apply). + try { + if (hasPreconditions(device)) { + const fixed = await device.fixPreconditions(); + if (!fixed.ok) { + const unfixable = fixed.issues.find((i) => !i.fixable); + return { + error: { + code: 502, + message: `device precondition not satisfied: ${unfixable?.message ?? fixed.issues[0]?.message}`, + }, + }; + } + } + + if (isHardenable(device)) { + const { secrets, warnings } = await device.harden(); + Object.assign(fullConfig, secrets); // e.g. relayPassword + // Surface residual-risk warnings (e.g. firmware that won't disable the + // password-less string protocol) so the admin can act (web-UI step). + for (const w of warnings ?? []) { + app.log.warn(`harden(${driverId} ${id}): ${w}`); + hardenWarnings.push(w); + } + } + + if (hasPushConfig(device)) { + const host = String(config.host ?? ""); + // Admin-provided backend IP wins; else auto-derive (on-subnet NIC). + const pushHost = backendIp ?? backendIpForDevice(host); + if (!pushHost) { + return { + error: { + code: 400, + message: `cannot determine the backend IP on the device's subnet (${host}). Pick one in setup or set BACKEND_HOST_IP.`, + }, + }; + } + const pushUser = "dingtian"; + // 24 hex chars = 96 bits. The Dingtian `pass` field caps at 31 chars + // (longer is silently truncated → auth mismatch), so keep it short. + const pushPassword = randomBytes(12).toString("hex"); + await device.configureInputPush({ + host: pushHost, + port: backendPort(), + pathBase: `/api/devices/${driverId}/${id}/input`, + auth: { user: pushUser, password: pushPassword }, + }); + fullConfig.pushUser = pushUser; + fullConfig.pushPassword = pushPassword; + // Record the backend IP the device was told to push to — lets us detect + // a later mismatch if the host's IP changes. + fullConfig.backendIp = pushHost; + } + } catch (err) { + return { error: { code: 502, message: `device configuration failed: ${(err as Error).message}` } }; + } + + return { config: fullConfig, warnings: hardenWarnings }; +} + export async function setupRoutes(app: FastifyInstance, db: Db): Promise { registerBuiltinDrivers(); setDeviceLogSink((line) => app.log.info(line)); @@ -166,100 +287,69 @@ export async function setupRoutes(app: FastifyInstance, db: Db): Promise { } const id = randomUUID(); - const fullConfig: Record = { ...config }; - // The web password the admin typed is a DESIRED value, not a stored fact: - // it's passed to the driver (via create(config) below) as the rotation - // target, but we do NOT persist it from the form. Only harden()'s VERIFIED - // secrets.webPassword gets saved — otherwise a failed rotation would leave - // the DB claiming a password the device never accepted (login stays old). - delete fullConfig.webPassword; - // webPasswordCurrent is an input-only credential (the OLD password used to - // authorize the change) — never persist it as typed. - delete fullConfig.webPasswordCurrent; - // Residual-risk warnings from device hardening (shown to the admin; the - // save still succeeds — these are "configured, but note X" advisories). - const hardenWarnings: string[] = []; - - let device; - try { - device = registry.create(driverId, config); // validates required fields - } catch (err) { - return reply.code(400).send({ error: (err as Error).message }); - } - - // Configure the device on save (before persisting, so we don't store a row - // for a device we couldn't configure): - // 1. fix preconditions (e.g. disable input_link_relay so a button press - // doesn't auto-fire its relay — host must decide first), - // 2. harden (relay password + disable unused protocol channels), and - // 3. set up input push (Digest creds + push URLs). - // Each step is a device config write (the device reboots on apply). - try { - if (hasPreconditions(device)) { - const fixed = await device.fixPreconditions(); - if (!fixed.ok) { - const unfixable = fixed.issues.find((i) => !i.fixable); - return reply.code(502).send({ - error: `device precondition not satisfied: ${unfixable?.message ?? fixed.issues[0]?.message}`, - }); - } - } - - if (isHardenable(device)) { - const { secrets, warnings } = await device.harden(); - Object.assign(fullConfig, secrets); // e.g. relayPassword - // Surface residual-risk warnings (e.g. firmware that won't disable the - // password-less string protocol) so the admin can act (web-UI step). - for (const w of warnings ?? []) { - app.log.warn(`harden(${driverId} ${id}): ${w}`); - hardenWarnings.push(w); - } - } - - if (hasPushConfig(device)) { - const host = String(config.host ?? ""); - // Admin-provided backend IP wins; else auto-derive (on-subnet NIC). - const pushHost = backendIp ?? backendIpForDevice(host); - if (!pushHost) { - return reply.code(400).send({ - error: `cannot determine the backend IP on the device's subnet (${host}). Pick one in setup or set BACKEND_HOST_IP.`, - }); - } - const pushUser = "dingtian"; - // 24 hex chars = 96 bits. The Dingtian `pass` field caps at 31 chars - // (longer is silently truncated → auth mismatch), so keep it short. - const pushPassword = randomBytes(12).toString("hex"); - await device.configureInputPush({ - host: pushHost, - port: backendPort(), - pathBase: `/api/devices/${driverId}/${id}/input`, - auth: { user: pushUser, password: pushPassword }, - }); - fullConfig.pushUser = pushUser; - fullConfig.pushPassword = pushPassword; - // Record the backend IP the device was told to push to — lets us detect - // a later mismatch if the host's IP changes. - fullConfig.backendIp = pushHost; - } - } catch (err) { - return reply - .code(502) - .send({ error: `device configuration failed: ${(err as Error).message}` }); + const outcome = await configureDevice(app, { id, driverId, config, backendIp }); + if ("error" in outcome) { + return reply.code(outcome.error.code).send({ error: outcome.error.message }); } const row = { id, category, driverId, - config: fullConfig, + config: outcome.config, enabled: true, }; await db.insert(devices).values(row); // Don't echo device secrets back (push Digest password, web-UI login, …). return reply.code(201).send({ ...row, - config: redactSecrets(fullConfig), - ...(hardenWarnings.length ? { warnings: hardenWarnings } : {}), + config: redactSecrets(outcome.config), + ...(outcome.warnings.length ? { warnings: outcome.warnings } : {}), + }); + }, + ); + + // Edit an assigned device in place. Same configure pipeline as assign, but it + // UPDATEs the existing row and KEEPS the id — which matters for controllers, + // since the id is baked into the device's input-push URL + // (/api/devices/:driverId/:id/input). Delete+re-add would mint a new id and + // break push until reconfigured; PATCH re-runs harden/push against the same id. + // The category and driver are fixed at create time (an edit can't change what + // KIND of device a slot is); only config changes. Admin-only. + app.patch<{ Params: { id: string }; Body: Omit }>( + "/api/setup/assign/:id", + { preHandler: adminGuard }, + async (req, reply) => { + const existing = await db + .select() + .from(devices) + .where(eq(devices.id, req.params.id)) + .get(); + if (!existing) return reply.code(404).send({ error: "no such device assignment" }); + + const { config, backendIp } = req.body; + const outcome = await configureDevice(app, { + id: existing.id, + driverId: existing.driverId, + config, + backendIp, + // Carry forward machine-only secrets the client never received, so an + // edit that omits them doesn't blank out push/relay passwords. + existingConfig: existing.config, + }); + if ("error" in outcome) { + return reply.code(outcome.error.code).send({ error: outcome.error.message }); + } + + await db.update(devices).set({ config: outcome.config }).where(eq(devices.id, existing.id)); + app.log.info(`reconfigured device ${existing.id} (${existing.category}/${existing.driverId})`); + return reply.code(200).send({ + id: existing.id, + category: existing.category, + driverId: existing.driverId, + config: redactSecrets(outcome.config), + enabled: existing.enabled, + ...(outcome.warnings.length ? { warnings: outcome.warnings } : {}), }); }, ); diff --git a/apps/server/src/routes/site.ts b/apps/server/src/routes/site.ts index d86b63c..d3bb8ad 100644 --- a/apps/server/src/routes/site.ts +++ b/apps/server/src/routes/site.ts @@ -7,9 +7,36 @@ import { getOccupancy } from "../occupancy.js"; // ledger; capacity is an admin-set knob. The FULL gate (refuse transient entry at // capacity) lives in the entry flow. See wiki/concepts/capacity-occupancy.md. -interface SiteConfigBody { +// Optional park-metadata text fields (all nullable). Trimmed; "" → null. +const TEXT_FIELDS = [ + "parkName", + "operatorName", + "nius", + "address", + "phone", + "email", +] as const; +type TextField = (typeof TEXT_FIELDS)[number]; + +interface SiteConfigBody extends Partial> { /** Nominal capacity; null = no limit. */ - capacity: number | null; + capacity?: number | null; +} + +/** Shape returned by GET/PUT: capacity + every metadata field (null when unset). */ +type SiteConfig = { capacity: number | null } & Record; + +function toSiteConfig(row: typeof siteConfig.$inferSelect | undefined): SiteConfig { + const out = { capacity: row?.capacity ?? null } as SiteConfig; + for (const f of TEXT_FIELDS) out[f] = row?.[f] ?? null; + return out; +} + +/** Trim a text field; empty string becomes null so blank input clears it. */ +function normText(v: unknown): string | null { + if (v == null) return null; + const s = String(v).trim(); + return s === "" ? null : s; } export async function siteRoutes(app: FastifyInstance, db: Db): Promise { @@ -19,25 +46,37 @@ export async function siteRoutes(app: FastifyInstance, db: Db): Promise { // Live occupancy: cars inside, capacity, free, full. Any signed-in role. app.get("/api/occupancy", { preHandler: readGuard }, async () => getOccupancy(db)); - // Read site config (capacity). + // Read site config (capacity + park metadata). app.get("/api/site-config", { preHandler: readGuard }, async () => { const row = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get(); - return { capacity: row?.capacity ?? null }; + return toSiteConfig(row); }); - // Set capacity (admin). null or 0+ integer. + // Set site config (admin). Capacity: null or 0+ integer. Metadata: optional text + // (only the fields PRESENT in the body are updated; absent fields are untouched). app.put<{ Body: SiteConfigBody }>("/api/site-config", { preHandler: writeGuard }, async (req, reply) => { - const { capacity } = req.body ?? ({} as SiteConfigBody); - if (capacity != null && (!Number.isInteger(capacity) || capacity < 0)) { - return reply.code(400).send({ error: "capacity must be a non-negative integer or null" }); + const body = req.body ?? ({} as SiteConfigBody); + + const patch: Partial = {}; + if ("capacity" in body) { + const c = body.capacity; + if (c != null && (!Number.isInteger(c) || c < 0)) { + return reply.code(400).send({ error: "capacity must be a non-negative integer or null" }); + } + patch.capacity = c ?? null; } + for (const f of TEXT_FIELDS) { + if (f in body) patch[f] = normText(body[f]); + } + const existing = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get(); const updatedAt = new Date().toISOString(); if (existing) { - db.update(siteConfig).set({ capacity: capacity ?? null, updatedAt }).where(eq(siteConfig.id, 1)).run(); + db.update(siteConfig).set({ ...patch, updatedAt }).where(eq(siteConfig.id, 1)).run(); } else { - db.insert(siteConfig).values({ id: 1, capacity: capacity ?? null, updatedAt }).run(); + db.insert(siteConfig).values({ id: 1, ...patch, updatedAt }).run(); } - return { capacity: capacity ?? null }; + const row = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get(); + return toSiteConfig(row); }); } diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 498579b..b66ba19 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -13,7 +13,7 @@ import { PermitFlow } from "./permit-flow.js"; import { ShiftService } from "./shift-service.js"; import { ReadDispatcher } from "./read-dispatch.js"; import { PrinterMonitor } from "./printer-monitor.js"; -import { buildSigner } from "./signer.js"; +import { buildSigner, buildVerifier } from "./signer.js"; import { authRoutes } from "./routes/auth.js"; import { deviceRoutes } from "./routes/devices.js"; import { eventRoutes } from "./routes/events.js"; @@ -86,7 +86,7 @@ export async function buildServer(opts: BuildOptions = {}): Promise void }): Signer { "event signing: no signing key. Set EVENT_SIGNING_KEY (>=16 chars) for the append-only event chain.", ); } + +/** + * Resolve the signer that can VERIFY an existing event, by its stored `keyId`. + * Appends always use the one signer from buildSigner(), but a chain can contain + * events signed under different keys across a rotation (e.g. the JWT_SECRET + * fallback before a dedicated EVENT_SIGNING_KEY was set, or an ATECC608 swap). + * Each event stores its own `keyId`, so verifyChain() must check each row against + * the key that produced it — not the current append-signer. Returns undefined for + * an unknown keyId (the key is gone / not configured), which verifyChain surfaces + * as a distinct failure rather than a false "tampered" alarm. + * + * TODO(atecc608): add an "atecc608-slotN" case returning a public-key verifier. + */ +export function buildVerifier(keyId: string): Signer | undefined { + switch (keyId) { + case "sw-hmac-v2": { + const k = process.env.EVENT_SIGNING_KEY; + return k && k.length >= 16 ? new SoftwareSigner(k, "sw-hmac-v2") : undefined; + } + case "sw-hmac-jwtfallback": { + const k = process.env.JWT_SECRET; + return k && k.length >= 16 ? new SoftwareSigner(k, "sw-hmac-jwtfallback") : undefined; + } + default: + return undefined; + } +} diff --git a/apps/web/src/SetupWizard.tsx b/apps/web/src/SetupWizard.tsx index b1f385a..b4d8513 100644 --- a/apps/web/src/SetupWizard.tsx +++ b/apps/web/src/SetupWizard.tsx @@ -1,6 +1,7 @@ import { useState, useEffect, useCallback } from "react"; import { assignDevice, + editDevice, discoverDevices, fetchBackendIps, fetchCatalog, @@ -127,8 +128,12 @@ function CategorySection({ onChanged: () => Promise | void; }) { const [adding, setAdding] = useState(false); + const [editingId, setEditingId] = useState(null); const [warnings, setWarnings] = useState([]); - const showForm = adding || assignments.length === 0; + const editing = editingId ? assignments.find((a) => a.id === editingId) : undefined; + // Show the add form for an empty category or an explicit "+ Add", but not while + // editing an existing row (that row renders its own inline form). + const showForm = !editing && (adding || assignments.length === 0); // Binding categories need a controller to point at first. const isBound = category !== "access"; @@ -162,15 +167,43 @@ function CategorySection({ {assignments.length > 0 && (
    - {assignments.map((a) => ( - - ))} + {assignments.map((a) => + editingId === a.id ? ( +
  • + { + setWarnings(w); + await onChanged(); + setEditingId(null); + }} + onCancel={() => setEditingId(null)} + /> +
  • + ) : ( + { + setAdding(false); + setEditingId(a.id); + }} + /> + ), + )}
)} {blockedNoController ? (

Add a controller first — a {noun} points at one of its relays.

- ) : showForm ? ( + ) : editing ? null : showForm ? ( Promise | void; + onEdit: () => void; }) { const [removing, setRemoving] = useState(false); const [error, setError] = useState(null); @@ -237,6 +272,9 @@ function AssignmentRow({ {!assignment.enabled && (disabled)} {error && {error}} + @@ -280,6 +318,7 @@ function DeviceForm({ discoverableIds, pushCapableIds, controllers, + editing, onSaved, onCancel, }: { @@ -288,21 +327,42 @@ function DeviceForm({ discoverableIds: string[]; pushCapableIds: string[]; controllers: Assignment[]; + /** When set, the form edits this assignment in place (driver locked, config + * pre-filled) instead of adding a new device. */ + editing?: Assignment; onSaved: (warnings: string[]) => Promise | void; onCancel?: () => void; }) { - const [selectedId, setSelectedId] = useState(""); + // On edit the driver is fixed (you can't change what KIND of device a slot is — + // that's a remove + re-add); pre-select it and lock the picker. + const editCfg = editing?.config as Record | undefined; + const [selectedId, setSelectedId] = useState(editing?.driverId ?? ""); const selected = entries.find((e) => e.id === selectedId); - const canDiscover = selected != null && discoverableIds.includes(selected.id); + const canDiscover = !editing && selected != null && discoverableIds.includes(selected.id); const pushesToBackend = selected != null && pushCapableIds.includes(selected.id); const isController = category === "access"; - const [config, setConfig] = useState>({}); + // Pre-fill scalar config fields from the existing assignment when editing. + // (relays/controllerId/relay are model fields handled by their own state below.) + const [config, setConfig] = useState>(() => { + if (!editCfg) return {}; + const out: Record = {}; + for (const [k, v] of Object.entries(editCfg)) { + if (typeof v === "string" || typeof v === "number") out[k] = v; + } + return out; + }); // Controllers: the relay map (which relay = entry/exit/both, + entry button terminal). - const [relays, setRelays] = useState([{ relay: 1, direction: "both" }]); + const [relays, setRelays] = useState(() => + Array.isArray(editCfg?.relays) ? (editCfg!.relays as RelaySpec[]) : [{ relay: 1, direction: "both" }], + ); // Bound devices: which controller + relay this device sits at. - const [controllerId, setControllerId] = useState(""); - const [boundRelay, setBoundRelay] = useState(""); + const [controllerId, setControllerId] = useState( + typeof editCfg?.controllerId === "string" ? editCfg.controllerId : "", + ); + const [boundRelay, setBoundRelay] = useState( + typeof editCfg?.relay === "number" ? editCfg.relay : "", + ); const [tested, setTested] = useState(null); const [testing, setTesting] = useState(false); @@ -420,12 +480,17 @@ function DeviceForm({ setSaving(true); setSaveError(null); try { - const result = await assignDevice({ - category, - driverId: selected.id, - config: mergedConfig(), - ...(backendIp ? { backendIp } : {}), - }); + const result = editing + ? await editDevice(editing.id, { + config: mergedConfig(), + ...(backendIp ? { backendIp } : {}), + }) + : await assignDevice({ + category, + driverId: selected.id, + config: mergedConfig(), + ...(backendIp ? { backendIp } : {}), + }); await onSaved(result.warnings ?? []); } catch (e) { setSaveError((e as Error).message); @@ -439,7 +504,9 @@ function DeviceForm({ {entries.length === 0 ? ( No drivers registered. ) : ( - selectDriver(e.target.value)} disabled={!!editing}> @@ -538,7 +605,7 @@ function DeviceForm({ {testing ? "Testing…" : "Test connection"} {onCancel && ( - {msg && {msg}} + +
+ Park details (optional — shown on tickets/receipts) +
+ {META_FIELDS.map(({ key, label, placeholder, multiline }) => ( +