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:
parent
f0b4ac3c41
commit
2db7d5f4d0
16 changed files with 757 additions and 14 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
71
backend/src/routes/kiosk.js
Normal file
71
backend/src/routes/kiosk.js
Normal 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 }
|
||||
})
|
||||
}
|
||||
|
|
@ -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 }
|
||||
})
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue