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) { +
+ +
@@ -918,12 +924,25 @@ export function KiosksPage(props: KiosksProps) { {props.pendingCodes.length > 0 && (
Pending Codes
- {props.pendingCodes.map((pc) => ( -
- {pc.code} - {formatTime(pc.expires_at)} -
- ))} + {props.pendingCodes.map((pc) => { + const managed = pc.extras?.["managed_image"] === true; + return ( +
+
+ {pc.code} + expires {formatTime(pc.expires_at)} +
+
+ {pc.kiosk_proposed_name ? <>name: {pc.kiosk_proposed_name} : "(no name)"} + {pc.kiosk_hardware_model ? <> · hw: {pc.kiosk_hardware_model} : null} + {managed ? <> · managed image : null} +
+ {pc.kiosk_capabilities?.length > 0 ? ( +
caps: {pc.kiosk_capabilities.join(", ")}
+ ) : null} +
+ ); + })}
)}