Gate both kiosks behind a device token, and make an unreachable setting a test failure

This commit is contained in:
2026-09-02 18:11:01 -04:00
parent 2f90419ceb
commit 4b21aa3887
14 changed files with 498 additions and 66 deletions

View File

@@ -11,6 +11,54 @@ const api = axios.create({
})
// Add auth token and normalize pagination params
// Kiosk device tokens. The kiosk routes are the only ones that write without a
// login, so each is additionally gated on a token an administrator provisioned
// into THAT screen's browser - see the kiosk views, which claim it from a
// ?token= URL once and strip it from the address bar. A token served inside the
// page would be readable by anyone who could load the page, so it is per-device
// and never shipped with the bundle.
//
// One entry per kiosk: the two are separate screens in separate rooms and a
// shared token would mean provisioning one grants the other.
export const KIOSK_TOKEN_KEYS = {
'/printedparts/kiosk/': 'printedparts-kiosk-token',
'/usb/kiosk/': 'usb-kiosk-token',
}
function kioskTokenFor(url) {
const path = url || ''
for (const [prefix, storageKey] of Object.entries(KIOSK_TOKEN_KEYS)) {
if (path.includes(prefix)) {
try { return localStorage.getItem(storageKey) || '' } catch { return '' }
}
}
return ''
}
// Claim a kiosk's device token, once, from the URL an admin used to set the
// screen up. Kept in THIS browser and stripped from the address bar straight
// away, so it does not sit there for a shoulder to read or travel in a bookmark
// or a screenshot.
export function claimKioskToken(storageKey) {
try {
const url = new URL(window.location.href)
const token = (url.searchParams.get('token') || '').trim()
if (!token) return
localStorage.setItem(storageKey, token)
url.searchParams.delete('token')
window.history.replaceState({}, '', url.pathname + url.search + url.hash)
} catch {
// A browser refusing localStorage cannot be provisioned. Nothing to
// recover; the routes 404 and the operator sees the standard error.
}
}
api.interceptors.request.use(config => {
const token = kioskTokenFor(config.url)
if (token) config.headers['X-Kiosk-Token'] = token
return config
})
api.interceptors.request.use(config => {
const token = localStorage.getItem('token')
if (token) {
@@ -1501,17 +1549,6 @@ export const measuringtoolsApi = {
}
// 3D printed parts (printedparts plugin)
// Device token for the parts kiosk, provisioned once per screen and kept in
// that browser. Absent means the routes answer 404, which is what an
// unprovisioned browser should see.
export const KIOSK_TOKEN_KEY = 'printedparts-kiosk-token'
function kioskHeaders() {
let token = ''
try { token = localStorage.getItem(KIOSK_TOKEN_KEY) || '' } catch { token = '' }
return token ? { 'X-Kiosk-Token': token } : {}
}
export const printedpartsApi = {
list(params = {}) {
return api.get('/printedparts/items', { params })
@@ -1552,17 +1589,11 @@ export const printedpartsApi = {
adjust(printeditemid, data) {
return api.post(`/printedparts/items/${printeditemid}/adjust`, data)
},
// The kiosk routes are the only unauthenticated write in the product, so they
// are gated on a per-DEVICE token rather than on the page. It lives in this
// browser's localStorage, put there once by an admin opening the kiosk with
// ?token=... - see PartsKiosk.vue. A token shipped inside the page would be
// readable by anyone who could load it, which is not a gate.
kioskItem(itemcode) {
return api.get(`/printedparts/kiosk/item/${encodeURIComponent(itemcode)}`,
{ headers: kioskHeaders() })
return api.get(`/printedparts/kiosk/item/${encodeURIComponent(itemcode)}`)
},
kioskTake(data) {
return api.post('/printedparts/kiosk/take', data, { headers: kioskHeaders() })
return api.post('/printedparts/kiosk/take', data)
},
listFiles(printeditemid) {
return api.get(`/printedparts/items/${printeditemid}/files`)