Files
shopdb-flask/plugins/printedparts/frontend/views/PartsKiosk.vue
cproudlock 744bc4341a Shut the parts kiosk behind a device token, and stop the health probe leaking
Both from the second Darkwing scan. Every claim in it was verified against the
code before acting.

The kiosk was the product's only unauthenticated write, justified by a decision
record saying it was "physically rate-limited: it serves a touch screen on the
shop floor". Nothing in code enforced the physical half, and an HTTP route is
reachable from wherever the app is. What that left open:

  - _kiosk_find_item falls back to matching bare digits against the numeric
    tail of every active item, so 1, 2, 3 walk the catalogue without knowing a
    barcode;
  - a badge is a short numeric SSO, and the route answered 422 for an unknown
    one and 200 for a real one - an oracle for sorting guesses into employees;
  - the write then decremented stock and stamped the ledger, which IS the audit
    trail, with whatever SSO the caller supplied;
  - quantity was bounded only by what was on hand, so one request emptied a bin.

A badge is attribution, never authorization. Both kiosk routes now need the
site's kiosk flag AND a per-device token, and 404 rather than 403 without them
so a caller does not learn the door is there. The READ is gated too: gating only
the write would have left the enumeration oracle running against a kiosk a site
had switched off.

The token is per-DEVICE, not per-page, because a kiosk is a browser on a touch
screen and a token served inside the page is readable by anyone who can load it.
An admin opens the screen once as /parts-kiosk?token=..., the page moves it into
that browser's localStorage and strips it from the address bar.

Also: quantity is capped per take, JSON true no longer passes as 1
(isinstance(True, int) is True), and every badge failure on the anonymous route
returns one message. The gated restock and adjust handlers keep the specific
message - there the person is known and the detail is help, not an oracle. I
changed the wrong one of those three handlers first; caught by the test.

/api/dashboard/health formatted str(e) straight into the body of a route
PUBLIC_ENDPOINTS declares open because "monitoring has no account". A pymysql
OperationalError names the database account, the app host's address, the driver
and the failing statement. It is logged server-side now and the body carries a
coarse status. The version goes with it - it told an anonymous caller which
release to look up. Status stays 200 either way: a monitor keyed on the body's
status field is the contract here, and turning a degraded database into a
transport failure is a separate decision from not leaking the exception.

Nine tests. The three gate tests were confirmed to fail with the gate stubbed
out, rather than assumed to cover it. Suite 2325, frontend 316, build clean.

Scope: product
2026-09-02 17:42:54 -04:00

365 lines
12 KiB
Vue

<template>
<div class="parts-kiosk" @click="focusEntry">
<header class="kiosk-header">
<h1>3D Printed Parts</h1>
<button v-if="step !== 'item'" class="btn btn-secondary" @click="reset">
Start over
</button>
</header>
<div v-if="error" class="kiosk-error">{{ error }}</div>
<!-- step 1: the bin label -->
<section v-if="step === 'item'" class="kiosk-step">
<p class="kiosk-prompt">Scan the barcode on the bin</p>
<!-- one visible field: keyboard-wedge scanners, a plugged-in keyboard or
numpad, and the on-screen keypad all type into it. -->
<div class="entry-panel">
<!-- The tag prefix is shown as a fixed addon so operators type only
the number part off the label. -->
<div class="entry-input-group">
<span v-if="LABEL_PREFIX" class="entry-prefix">{{ LABEL_PREFIX }}</span>
<input ref="entryInput" v-model="manualCode" class="entry-input has-prefix"
inputmode="none" autocomplete="off" placeholder="number"
@keydown.enter.prevent="lookupItem(manualCode)" />
</div>
<TouchKeypad @digit="manualCode += $event"
@clear="manualCode = ''"
@backspace="manualCode = manualCode.slice(0, -1)" />
<button class="btn btn-primary take-button" :disabled="!manualCode"
@click="lookupItem(manualCode)">Look up</button>
<p class="kiosk-hint">Scan the label, type on a keyboard, or tap it in;
letters are added automatically.</p>
</div>
</section>
<!-- step 2: badge -->
<section v-else-if="step === 'badge'" class="kiosk-step">
<div class="item-card">
<img v-if="item.imageurl" :src="withBase(item.imageurl)" class="item-photo" />
<div>
<h2>{{ item.itemname }}</h2>
<p class="kiosk-hint">{{ item.gagelabtag || item.itemcode }} - {{ item.quantityonhand }} on hand</p>
<p v-if="scannedRevision != null" class="kiosk-rev">Revision {{ scannedRevision }}</p>
</div>
</div>
<p class="kiosk-prompt">Scan your badge or tap in your SSO</p>
<div class="entry-panel">
<input ref="entryInput" v-model="manualBadge" class="entry-input"
inputmode="none" autocomplete="off" placeholder="SSO"
@keydown.enter.prevent="acceptBadge(manualBadge)" />
<TouchKeypad @digit="manualBadge += $event"
@clear="manualBadge = ''"
@backspace="manualBadge = manualBadge.slice(0, -1)" />
<button class="btn btn-primary take-button" :disabled="!manualBadge"
@click="acceptBadge(manualBadge)">Next</button>
</div>
</section>
<!-- step 3: quantity -->
<section v-else-if="step === 'quantity'" class="kiosk-step">
<div class="item-card">
<img v-if="item.imageurl" :src="withBase(item.imageurl)" class="item-photo" />
<div>
<h2>{{ item.itemname }}</h2>
<p class="kiosk-hint">{{ item.quantityonhand }} on hand</p>
<p v-if="scannedRevision != null" class="kiosk-rev">Revision {{ scannedRevision }}</p>
</div>
</div>
<p class="kiosk-prompt">How many are you taking?</p>
<div class="entry-panel">
<input ref="entryInput" v-model="quantity" class="entry-input"
inputmode="none" autocomplete="off" placeholder="0"
@keydown.enter.prevent="submitTake" />
<TouchKeypad @digit="quantity += $event"
@clear="quantity = ''"
@backspace="quantity = quantity.slice(0, -1)" />
<button class="btn btn-primary take-button" :disabled="!quantity || submitting"
@click="submitTake">
{{ submitting ? 'Working...' : 'TAKE' }}
</button>
</div>
</section>
<!-- done -->
<section v-else-if="step === 'done'" class="kiosk-step">
<p class="kiosk-success">Done - {{ doneMessage }}</p>
<p v-if="scannedRevision != null" class="kiosk-hint">Checked out revision {{ scannedRevision }}</p>
<p class="kiosk-hint">Starting over in a few seconds...</p>
</section>
</div>
</template>
<script setup>
import { ref, onMounted, onBeforeUnmount, nextTick } from 'vue'
import { printedpartsApi, settingsApi, KIOSK_TOKEN_KEY } from '@/api'
import { withBase } from '@/utils/basePath'
import TouchKeypad from '@/components/TouchKeypad.vue'
// Leading text on the physical labels, shown before the number box so operators
// type only the digits. It was hardcoded to 'WJ', which is one site's tag
// format and nobody else's - on another site's kiosk it told operators to expect
// letters that are not on their labels. Display only: the lookup sends whatever
// was typed, unprefixed.
const LABEL_PREFIX = ref('')
const step = ref('item')
const item = ref(null)
const badge = ref('')
const quantity = ref('')
const error = ref('')
const doneMessage = ref('')
const submitting = ref(false)
const manualCode = ref('')
const manualBadge = ref('')
const scannedRevision = ref(null)
const entryInput = ref(null)
let resetTimer = null
// Claim the device token, once, from the URL an admin used to set this screen
// up: /parts-kiosk?token=<value>. It is kept in THIS browser and stripped from
// the address bar immediately, so it does not sit in the URL for a shoulder to
// read, and does not travel in a bookmark or a screenshot. Every kiosk call
// then carries it as X-Kiosk-Token; without it the routes answer 404.
function claimTokenFromUrl() {
try {
const url = new URL(window.location.href)
const token = (url.searchParams.get('token') || '').trim()
if (!token) return
localStorage.setItem(KIOSK_TOKEN_KEY, token)
url.searchParams.delete('token')
window.history.replaceState({}, '', url.pathname + url.search + url.hash)
} catch {
// A browser refusing localStorage is a browser this kiosk cannot be
// provisioned in. Nothing to recover here; the routes will 404 and the
// operator sees the standard error.
}
}
onMounted(async () => {
claimTokenFromUrl()
// A kiosk left running for weeks reads this once at load, which is fine: the
// label format is a property of the site's physical tags, not something that
// changes during a shift.
try {
const response = await settingsApi.get('printedparts_label_prefix')
LABEL_PREFIX.value = (response.data?.data?.value || '').trim()
} catch (err) {
// Never block the kiosk over a cosmetic hint. An unreachable or unseeded
// setting simply means no prefix is shown.
LABEL_PREFIX.value = ''
}
focusEntry()
})
onBeforeUnmount(() => clearTimeout(resetTimer))
function focusEntry(event) {
// Keep focus on the entry input so a wedge scanner or plugged-in keyboard
// always types into it. Only reclaim focus from dead space - never steal it
// mid-tap from a button or another control.
const tag = event?.target?.tagName
if (tag === 'INPUT' || tag === 'SELECT' || tag === 'TEXTAREA'
|| tag === 'BUTTON' || tag === 'A') return
nextTick(() => entryInput.value?.focus())
}
async function lookupItem(itemcode) {
error.value = ''
const code = (itemcode || '').trim()
if (!code) return
// A label QR carries "TAG|rev"; capture the revision, resolve by the TAG part.
const pipe = code.indexOf('|')
scannedRevision.value = pipe >= 0 ? (parseInt(code.slice(pipe + 1), 10) || null) : null
try {
const response = await printedpartsApi.kioskItem(code)
item.value = response.data.data
step.value = 'badge'
manualCode.value = ''
} catch (lookupError) {
error.value = lookupError.response?.data?.data?.error?.message ||
'No part matches that barcode'
}
focusEntry()
}
function acceptBadge(value) {
error.value = ''
const scanned = (value || '').trim()
if (!scanned) return
badge.value = scanned
manualBadge.value = ''
step.value = 'quantity'
focusEntry()
}
async function submitTake() {
if (!quantity.value || submitting.value) return
submitting.value = true
error.value = ''
try {
const response = await printedpartsApi.kioskTake({
itemcode: item.value.itemcode,
badge: badge.value,
quantity: parseInt(quantity.value, 10),
revision: scannedRevision.value
})
doneMessage.value = response.data.message
step.value = 'done'
resetTimer = setTimeout(reset, 4000)
} catch (takeError) {
error.value = takeError.response?.data?.data?.error?.message ||
'Could not complete - see the parts team'
if (takeError.response?.status === 422) {
// badge problem: go back a step so the next scan retries cleanly
step.value = 'badge'
focusEntry()
}
} finally {
submitting.value = false
}
}
function reset() {
clearTimeout(resetTimer)
step.value = 'item'
item.value = null
badge.value = ''
quantity.value = ''
manualCode.value = ''
manualBadge.value = ''
scannedRevision.value = null
error.value = ''
doneMessage.value = ''
focusEntry()
}
</script>
<style scoped>
.parts-kiosk {
min-height: 100vh;
background: var(--bg);
color: var(--text);
padding: 2rem;
display: flex;
flex-direction: column;
align-items: center;
/* Centered as one block on the wall-mounted display, not hugging the top.
`safe` so a short screen (or a tall error + item card) scrolls from the
top instead of clipping the header off the edge, which plain `center`
does with no way to scroll back up. */
justify-content: center;
justify-content: safe center;
gap: 1.5rem;
}
.kiosk-header {
width: 100%;
max-width: 40rem;
display: flex;
justify-content: center;
align-items: center;
/* Title sits centered over the panel; Start over is pulled out of the flow
so it keeps its top-right corner instead of pushing the title off-center
on the steps that show it. */
position: relative;
}
.kiosk-header .btn {
position: absolute;
right: 0;
top: 50%;
transform: translateY(-50%);
}
.kiosk-step {
display: flex;
flex-direction: column;
align-items: center;
gap: 1.25rem;
max-width: 40rem;
width: 100%;
}
.kiosk-prompt { font-size: 1.6rem; font-weight: 600; }
.kiosk-hint { color: var(--text-light); }
.kiosk-rev { font-size: 1.3rem; font-weight: 700; color: var(--primary); margin-top: 0.25rem; }
.kiosk-error {
background: var(--danger);
color: #fff;
padding: 0.75rem 1.25rem;
border-radius: 0.5rem;
}
.kiosk-success { font-size: 1.6rem; color: var(--success); font-weight: 600; }
.item-card {
display: flex;
align-items: center;
gap: 1rem;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 0.6rem;
padding: 1rem 1.5rem;
width: 100%;
}
.item-photo {
width: 5rem;
height: 5rem;
object-fit: cover;
border-radius: 0.4rem;
}
.entry-panel {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 1rem;
padding: 1.5rem 2rem;
}
.entry-input {
width: 16.9rem;
box-sizing: border-box;
font-size: 2.4rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
text-align: center;
padding: 0.5rem 1rem;
border: 1px solid var(--border);
border-radius: 0.75rem;
background: var(--bg);
color: var(--text);
outline: none;
}
.entry-input:focus { border-color: var(--primary); }
.entry-input-group {
display: flex;
align-items: stretch;
width: 16.9rem;
}
.entry-prefix {
display: flex;
align-items: center;
padding: 0 0.7rem;
font-size: 2rem;
font-weight: 700;
color: var(--text-light);
background: var(--bg-card);
border: 1px solid var(--border);
border-right: none;
border-radius: 0.75rem 0 0 0.75rem;
}
.entry-input.has-prefix {
width: auto;
flex: 1;
min-width: 0;
border-radius: 0 0.75rem 0.75rem 0;
}
.entry-input::placeholder {
color: var(--text-light);
font-weight: 400;
font-size: 1.4rem;
}
.take-button {
font-size: 1.4rem;
padding: 0.85rem 0;
width: 16.9rem;
border-radius: 0.75rem;
}
</style>