mirror of
https://github.com/BetterCorp/BetterFrame.git
synced 2026-05-26 20:16:35 +00:00
feat(pairing): validate replace-target matches existing kiosk
Replacing a kiosk now sanity-checks the incoming device: - hardware_model must match (Pi 5 swapping in for Pi 5, not Pi 3) - managed_image flag must match (don't silently switch BYO-OS ↔ image) - capabilities can narrow legitimately but a "lost capabilities" diff is surfaced anyway so the operator notices. Mismatch raises an error listing what changed; "Force replace" checkbox on the pair form bypasses for legitimate hardware upgrades. Pending codes panel also now renders proposed_name / hw_model / capabilities / managed-image badge so the operator can eyeball the inbound device before picking a replace target.
This commit is contained in:
parent
6b959755e7
commit
d51e01ff0e
3 changed files with 62 additions and 6 deletions
|
|
@ -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 initialLabels = labelsStr ? labelsStr.split(",").map((s) => s.trim()).filter(Boolean) : undefined;
|
||||||
const replaceIdRaw = (body?.["replace_kiosk_id"] ?? "").trim();
|
const replaceIdRaw = (body?.["replace_kiosk_id"] ?? "").trim();
|
||||||
const replaceKioskId = replaceIdRaw && replaceIdRaw !== "0" ? Number(replaceIdRaw) : undefined;
|
const replaceKioskId = replaceIdRaw && replaceIdRaw !== "0" ? Number(replaceIdRaw) : undefined;
|
||||||
|
const force = body?.["force"] === "1";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await confirmPairing(deps.repo, deps.auth, deps.secrets, {
|
const result = await confirmPairing(deps.repo, deps.auth, deps.secrets, {
|
||||||
|
|
@ -751,6 +752,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||||
nameOverride,
|
nameOverride,
|
||||||
initialLabels,
|
initialLabels,
|
||||||
replaceKioskId,
|
replaceKioskId,
|
||||||
|
force,
|
||||||
});
|
});
|
||||||
audit(deps.repo, event as any, replaceKioskId ? "kiosk.replace" : "kiosk.pair", {
|
audit(deps.repo, event as any, replaceKioskId ? "kiosk.replace" : "kiosk.pair", {
|
||||||
resource_type: "kiosk",
|
resource_type: "kiosk",
|
||||||
|
|
|
||||||
|
|
@ -114,6 +114,8 @@ export interface PairingConfirmInput {
|
||||||
* When set, nameOverride and initialLabels are ignored.
|
* When set, nameOverride and initialLabels are ignored.
|
||||||
*/
|
*/
|
||||||
replaceKioskId?: number;
|
replaceKioskId?: number;
|
||||||
|
/** Bypass replacement-target sanity checks (hardware_model / capabilities / managed_image). */
|
||||||
|
force?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function confirmPairing(
|
export async function confirmPairing(
|
||||||
|
|
@ -137,12 +139,45 @@ export async function confirmPairing(
|
||||||
if (input.replaceKioskId != null) {
|
if (input.replaceKioskId != null) {
|
||||||
const existing = repo.getKioskById(input.replaceKioskId);
|
const existing = repo.getKioskById(input.replaceKioskId);
|
||||||
if (!existing) throw new Error("replacement target kiosk not found");
|
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, {
|
repo.replaceKioskKey(existing.id, {
|
||||||
key_hash: kioskKeyHash,
|
key_hash: kioskKeyHash,
|
||||||
key_prefix: kioskKeyPrefix,
|
key_prefix: kioskKeyPrefix,
|
||||||
capabilities: pc.kiosk_capabilities,
|
capabilities: pc.kiosk_capabilities,
|
||||||
hardware_model: pc.kiosk_hardware_model,
|
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;
|
kioskId = existing.id;
|
||||||
kioskName = existing.name;
|
kioskName = existing.name;
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -907,6 +907,12 @@ export function KiosksPage(props: KiosksProps) {
|
||||||
<label for="name_override">Name Override (new kiosks only)</label>
|
<label for="name_override">Name Override (new kiosks only)</label>
|
||||||
<input id="name_override" name="name_override" type="text" class="form-input" />
|
<input id="name_override" name="name_override" type="text" class="form-input" />
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label style="font-weight:normal">
|
||||||
|
<input type="checkbox" name="force" value="1" />
|
||||||
|
{" "}Force replace (skip hardware / capability / managed-image match check)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="initial_labels">Initial Labels (new kiosks only)</label>
|
<label for="initial_labels">Initial Labels (new kiosks only)</label>
|
||||||
<input id="initial_labels" name="initial_labels" type="text" class="form-input" placeholder="lobby, floor-1" />
|
<input id="initial_labels" name="initial_labels" type="text" class="form-input" placeholder="lobby, floor-1" />
|
||||||
|
|
@ -918,12 +924,25 @@ export function KiosksPage(props: KiosksProps) {
|
||||||
{props.pendingCodes.length > 0 && (
|
{props.pendingCodes.length > 0 && (
|
||||||
<div style="margin-top:1.25rem; border-top:1px solid #eee; padding-top:1rem">
|
<div style="margin-top:1.25rem; border-top:1px solid #eee; padding-top:1rem">
|
||||||
<div style="font-weight:600; font-size:0.85rem; margin-bottom:0.5rem">Pending Codes</div>
|
<div style="font-weight:600; font-size:0.85rem; margin-bottom:0.5rem">Pending Codes</div>
|
||||||
{props.pendingCodes.map((pc) => (
|
{props.pendingCodes.map((pc) => {
|
||||||
<div style="display:flex; justify-content:space-between; font-size:0.85rem; padding:0.25rem 0">
|
const managed = pc.extras?.["managed_image"] === true;
|
||||||
<code>{pc.code}</code>
|
return (
|
||||||
<span style="color:#666">{formatTime(pc.expires_at)}</span>
|
<div style="font-size:0.8rem; padding:0.4rem 0; border-top:1px dashed #eee">
|
||||||
|
<div style="display:flex; justify-content:space-between">
|
||||||
|
<code style="font-size:0.95rem">{pc.code}</code>
|
||||||
|
<span style="color:#666">expires {formatTime(pc.expires_at)}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
<div style="color:#666; margin-top:0.2rem">
|
||||||
|
{pc.kiosk_proposed_name ? <>name: <code>{pc.kiosk_proposed_name}</code></> : "(no name)"}
|
||||||
|
{pc.kiosk_hardware_model ? <> · hw: <code>{pc.kiosk_hardware_model}</code></> : null}
|
||||||
|
{managed ? <> · <span style="color:#080">managed image</span></> : null}
|
||||||
|
</div>
|
||||||
|
{pc.kiosk_capabilities?.length > 0 ? (
|
||||||
|
<div style="color:#666; margin-top:0.15rem">caps: {pc.kiosk_capabilities.join(", ")}</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue