Add kiosk wall-panel control view

Full-screen /kiosk route, separate from the staff SPA: dedicated long-lived
kiosk login (own password, hashed, mints its own hnf_session cookie locally
via CENTRAL_AUTH_SECRET -- no central auth changes needed), scrollable grid
of small mode/setpoint blocks per live-controllable device (shelly_trv,
mhi_mqtt, daikin), tap-to-edit modal reusing the existing per-type control
panels, and a type-aware bulk multiselect (separate TRV/AC sections, only
applied to matching selected devices, TRV commands staggered).

- backend/src/routes/kiosk.js: public POST /api/kiosk/login
- backend/src/routes/settings.js: admin set/check kiosk password (bcrypt)
- backend/src/routes/config.js: exclude/protect kiosk_password_hash
- frontend/src/pages/Kiosk.tsx + components/Kiosk*: the kiosk UI
- reuses GET /api/status for the combined device list -- no new status
  endpoint needed since the kiosk token now also carries the 'view' cap

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-08-25 20:44:50 +00:00
parent f0b4ac3c41
commit 2db7d5f4d0
16 changed files with 757 additions and 14 deletions

View file

@ -11,6 +11,7 @@
"@fastify/cors": "^9.0.1",
"@fastify/multipart": "^8.3.0",
"@fastify/static": "^7.0.4",
"bcryptjs": "^2.4.3",
"fastify": "^4.28.1",
"jose": "^5.9.6",
"luxon": "^3.5.0",

View file

@ -272,6 +272,12 @@ async function seedDefaults() {
maintenance_api_key: '',
hvac_defaults: DEFAULT_HVAC_TEMPLATE,
history_retention_days: 4, // how long device_readings rows are kept — see pruneOldReadings()
// Bcrypt hash of the kiosk wall-panel's login password — never the plaintext.
// NULL until an admin sets one via Settings -> Kiosk (see routes/kiosk.js /
// routes/settings.js). Deliberately excluded from GET /api/config's response
// (routes/config.js) and from the generic PUT /api/config/:key path — the only
// way to change it is POST /api/settings/kiosk-password, which hashes server-side.
kiosk_password_hash: null,
}
for (const [key, value] of Object.entries(defaults)) {
await pool.query(

View file

@ -18,6 +18,7 @@ import { overrideRoutes } from './routes/override.js'
import { configRoutes } from './routes/config.js'
import { settingsRoutes } from './routes/settings.js'
import { mhiGatewayRoutes } from './routes/mhi-gateways.js'
import { kioskRoutes } from './routes/kiosk.js'
const __dirname = dirname(fileURLToPath(import.meta.url))
const UPLOADS_DIR = join(__dirname, '..', 'uploads')
@ -46,6 +47,7 @@ await app.register(overrideRoutes)
await app.register(configRoutes)
await app.register(settingsRoutes)
await app.register(mhiGatewayRoutes)
await app.register(kioskRoutes)
try {
await initDb()

View file

@ -9,12 +9,19 @@ export async function configRoutes(app) {
app.addHook('preHandler', requireAuth)
app.get('/api/config', { preHandler: requireCap('view') }, async () => {
const { rows } = await pool.query('SELECT key, value FROM config ORDER BY key')
// kiosk_password_hash is deliberately never returned here — it's a bcrypt hash,
// not a secret to display, but there's no reason to hand it to every viewer
// either. See routes/settings.js for the "is a kiosk password set?" boolean.
const { rows } = await pool.query(`SELECT key, value FROM config WHERE key != 'kiosk_password_hash' ORDER BY key`)
return Object.fromEntries(rows.map(r => [r.key, r.value]))
})
app.put('/api/config/:key', { preHandler: requireCap('settings') }, async (req, reply) => {
const { key } = req.params
// Blocked here so the kiosk password can only ever be set via
// POST /api/settings/kiosk-password, which hashes it server-side — this generic
// key/value route would otherwise let it be overwritten with raw plaintext.
if (key === 'kiosk_password_hash') return reply.status(403).send({ error: 'Use POST /api/settings/kiosk-password instead' })
const { value } = req.body || {}
if (value === undefined) return reply.status(400).send({ error: 'value required' })
await pool.query(

View file

@ -0,0 +1,71 @@
import { SignJWT } from 'jose'
import bcrypt from 'bcryptjs'
import { getConfig } from '../db.js'
const DOMAIN = process.env.DOMAIN || 'localhost'
const secret = new TextEncoder().encode(process.env.CENTRAL_AUTH_SECRET || '')
// Same shape as central auth's own cookieOpts() (auth/src/routes/auth.js) —
// Secure only when the request genuinely arrived over HTTPS (NPM's
// X-Forwarded-Proto), domain scoped the same way, so this cookie behaves
// identically to one central auth issued.
function cookieOpts(request) {
const proto = request.headers['x-forwarded-proto'] || request.protocol
return {
httpOnly: true,
secure: proto === 'https',
sameSite: 'lax',
domain: DOMAIN === 'localhost' ? undefined : `.${DOMAIN}`,
path: '/',
maxAge: KIOSK_SESSION_SECONDS,
}
}
// ~10 years — deliberately near-permanent. This is the wall-panel kiosk's whole
// point: enter the password once per tablet, never again (see project_hvac_kiosk
// memory / kickoff brief). If CENTRAL_AUTH_SECRET is ever rotated, every kiosk
// tablet's cookie invalidates at once and needs re-entering — an accepted,
// expected consequence of shared-secret signing, not a bug.
const KIOSK_SESSION_SECONDS = 60 * 60 * 24 * 3650
// hvac's own requireAuth (../auth.js) verifies the hnf_session cookie itself
// against CENTRAL_AUTH_SECRET — it never calls back to central auth at request
// time. So hvac's backend, holding that same secret, can mint a valid session
// token directly. No central-auth changes needed at all; the resulting cookie
// is indistinguishable from one central auth issued, just narrower in scope.
export async function kioskRoutes(app) {
// Deliberately outside any requireAuth hook — this IS the login route.
app.post('/api/kiosk/login', async (req, reply) => {
const { password } = req.body || {}
if (!password || typeof password !== 'string') {
return reply.status(400).send({ error: 'Password required' })
}
const hash = await getConfig('kiosk_password_hash', null)
if (!hash) {
return reply.status(503).send({ error: 'Kiosk login has not been set up yet — set a Kiosk Password under Settings first.' })
}
const ok = await bcrypt.compare(password, hash)
if (!ok) return reply.status(401).send({ error: 'Incorrect password' })
// Deliberately narrow: 'view' + 'control' only — never 'settings' or
// 'manage_devices', so a token that lives on a wall-mounted tablet can
// never reach Settings, device assignment, or anything admin.
const token = await new SignJWT({
apps: ['hvac'],
caps: ['hvac:view', 'hvac:control'],
is_admin: false,
offsite_allowed: true, // it's a physical on-site kiosk — no IP-based onsite check needed
name: 'Kiosk',
})
.setProtectedHeader({ alg: 'HS256' })
.setSubject('hvac-kiosk')
.setIssuedAt()
.setExpirationTime(`${KIOSK_SESSION_SECONDS}s`)
.sign(secret)
reply.setCookie('hnf_session', token, cookieOpts(req))
return { ok: true }
})
}

View file

@ -1,6 +1,8 @@
import bcrypt from 'bcryptjs'
import { requireAuth, requireCap } from '../auth.js'
import { testConnection } from '../lib/newbook.js'
import { isConnected as mqttConnected } from '../lib/mqtt.js'
import { getConfig, setConfig } from '../db.js'
// Settings-page-backing endpoints that don't fit the generic config.js key/value
// store: connection tests and the MQTT broker's live status. The Home Assistant
@ -22,4 +24,22 @@ export async function settingsRoutes(app) {
: 'Not connected — the shared MQTT broker (LXC 104) may not be provisioned yet, or hvac-backend has no credentials in settings.',
}
})
// Kiosk wall-panel login (see routes/kiosk.js) — the password itself is never
// readable back, only whether one has been set, so the Settings page can show
// a "configured" state without exposing the hash to the browser.
app.get('/api/settings/kiosk-password-status', { preHandler: requireCap('settings') }, async () => {
const hash = await getConfig('kiosk_password_hash', null)
return { configured: !!hash }
})
app.post('/api/settings/kiosk-password', { preHandler: requireCap('settings') }, async (req, reply) => {
const { password } = req.body || {}
if (!password || typeof password !== 'string' || password.length < 6) {
return reply.status(400).send({ error: 'Password must be at least 6 characters' })
}
const hash = await bcrypt.hash(password, 12)
await setConfig('kiosk_password_hash', hash)
return { ok: true }
})
}