diff --git a/server/src/plugins/service-admin-http/routes-admin.ts b/server/src/plugins/service-admin-http/routes-admin.ts index 019c4f6..a12717b 100644 --- a/server/src/plugins/service-admin-http/routes-admin.ts +++ b/server/src/plugins/service-admin-http/routes-admin.ts @@ -744,6 +744,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { const initialLabels = labelsStr ? labelsStr.split(",").map((s) => s.trim()).filter(Boolean) : undefined; const replaceIdRaw = (body?.["replace_kiosk_id"] ?? "").trim(); const replaceKioskId = replaceIdRaw && replaceIdRaw !== "0" ? Number(replaceIdRaw) : undefined; + const force = body?.["force"] === "1"; try { const result = await confirmPairing(deps.repo, deps.auth, deps.secrets, { @@ -751,6 +752,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { nameOverride, initialLabels, replaceKioskId, + force, }); audit(deps.repo, event as any, replaceKioskId ? "kiosk.replace" : "kiosk.pair", { resource_type: "kiosk", diff --git a/server/src/shared/pairing.ts b/server/src/shared/pairing.ts index 39d83ed..7e63478 100644 --- a/server/src/shared/pairing.ts +++ b/server/src/shared/pairing.ts @@ -114,6 +114,8 @@ export interface PairingConfirmInput { * When set, nameOverride and initialLabels are ignored. */ replaceKioskId?: number; + /** Bypass replacement-target sanity checks (hardware_model / capabilities / managed_image). */ + force?: boolean; } export async function confirmPairing( @@ -137,12 +139,45 @@ export async function confirmPairing( if (input.replaceKioskId != null) { const existing = repo.getKioskById(input.replaceKioskId); if (!existing) throw new Error("replacement target kiosk not found"); + + // Sanity-check the incoming device matches the slot it's replacing. + // Identity-bearing fields (hardware_model, managed_image) shouldn't drift + // on a like-for-like swap. Capabilities CAN narrow legitimately, so warn + // but don't block. force=1 from the form bypasses the whole check. + if (!input.force) { + const mismatches: string[] = []; + const newHw = pc.kiosk_hardware_model ?? null; + if (existing.hardware_model && newHw && existing.hardware_model !== newHw) { + mismatches.push(`hardware_model: ${existing.hardware_model} → ${newHw}`); + } + const newManaged = pc.extras?.["managed_image"] === true; + if (existing.managed_image !== newManaged) { + mismatches.push(`managed_image: ${existing.managed_image} → ${newManaged}`); + } + const lostCaps = existing.capabilities.filter((c) => !pc.kiosk_capabilities.includes(c)); + if (lostCaps.length > 0) { + mismatches.push(`lost capabilities: ${lostCaps.join(", ")}`); + } + if (mismatches.length > 0) { + throw new Error( + `replacement device differs from existing kiosk — ${mismatches.join("; ")}. ` + + `Re-submit with "force replace" to override.`, + ); + } + } + repo.replaceKioskKey(existing.id, { key_hash: kioskKeyHash, key_prefix: kioskKeyPrefix, capabilities: pc.kiosk_capabilities, hardware_model: pc.kiosk_hardware_model, }); + // managed_image flag follows the new device (handled on row above via + // capabilities/hw, but the explicit column is updated separately because + // replaceKioskKey doesn't touch it). + if (existing.managed_image !== (pc.extras?.["managed_image"] === true)) { + repo.updateKiosk(existing.id, { managed_image: pc.extras?.["managed_image"] === true } as any); + } kioskId = existing.id; kioskName = existing.name; } else { diff --git a/server/src/web-templates/admin-pages.tsx b/server/src/web-templates/admin-pages.tsx index 222079b..0c3a488 100644 --- a/server/src/web-templates/admin-pages.tsx +++ b/server/src/web-templates/admin-pages.tsx @@ -907,6 +907,12 @@ export function KiosksPage(props: KiosksProps) { +
{pc.code}
- {formatTime(pc.expires_at)}
- {pc.code}
+ expires {formatTime(pc.expires_at)}
+ {pc.kiosk_proposed_name}> : "(no name)"}
+ {pc.kiosk_hardware_model ? <> · hw: {pc.kiosk_hardware_model}> : null}
+ {managed ? <> · managed image> : null}
+