Files
shopdb-flask/plugins/usb/frontend/views/USBKiosk.vue
cproudlock 7f6aaf2e64 Gate the USB kiosk the same way, and make an unreachable setting a test failure
The USB kiosk had a feature flag and nothing else. A flag is a decision, not a
credential: with the kiosk on, anyone who could reach the app could move a drive
between checked-out and checked-in and sign it with a badge they guessed. Same
reasoning that closed the parts kiosk, smaller blast radius - which is why
Darkwing did not raise it and why it was worth asking about anyway.

Both kiosks now share one mechanism. The device token moved out of the
printedparts api wrapper into a request interceptor that attaches X-Kiosk-Token
by URL prefix, with one localStorage key per kiosk - they are separate screens
in separate rooms, and a shared token would mean provisioning one grants the
other. claimKioskToken() is shared too, so both views take the token from a
?token= URL once and strip it from the address bar identically.

Then the part that matters more than either fix. I told you to set the new
settings in the UI, and that was impossible: every plugin settings page carries
a hardcoded key list, and nothing had added them. printedparts_kiosk_enabled,
printedparts_kiosk_token and usb_kiosk_token were declared, seeded and read
correctly, and rendered by no page - changeable only by writing SQL.

tests/test_plugin_settings_reachable.py now fails when any declared setting
appears nowhere in its plugin's frontend. It found seven MORE that were already
in that state, all pre-existing:

  collector_corporate_ranges          computers_configpolicies_enabled
  computers_machinelink_alerts        computers_quietreporthours
  geenforce_reportstaleminutes        printers_dashboardpercent
  warranty_expiringdays

All seven now have fields. I first parked them in a KNOWN_GAPS ratchet, then
fixed them and deleted the ratchet - a backlog list in a new check is a way of
agreeing the check does not apply yet. There is no backlog; the check is
absolute.

Two of my own mistakes on the way, both caught by tests rather than by reading:
the blanket rename of the USB guard also rewrote the call inside _kiosk_open, so
it called itself until the stack ran out; and the first version of the reachable
check skipped all 17 plugins because it looked for get_settings() when the
method is get_settings_defaults() - it passed by testing nothing, which is the
exact shape it exists to catch.

Suite 2336, frontend 316, build clean.

Scope: product
2026-09-02 18:08:34 -04:00

511 lines
17 KiB
Vue

<template>
<div class="usb-kiosk" @click="focusEntry">
<header class="kiosk-header">
<h1>USB Drives</h1>
<button v-if="step !== 'badge'" class="btn btn-secondary" @click="reset">
Done
</button>
</header>
<div v-if="disabled" class="kiosk-step">
<p class="kiosk-prompt">This kiosk is turned off.</p>
<p class="kiosk-hint">An administrator enables it in Settings under USB.</p>
</div>
<template v-else>
<div v-if="error" class="kiosk-error">{{ error }}</div>
<!--
Badge FIRST. The drives carry no barcode, so there is nothing to present
and the screen has to offer a list - which means the list has to cost
something. A badge that resolves to a real person is the same bar taking
a drive already has to clear.
-->
<section v-if="step === 'badge'" class="kiosk-step">
<p class="kiosk-prompt">Enter your SSO to start</p>
<div class="entry-panel">
<input ref="entryInput" v-model="manualBadge" class="entry-input"
inputmode="none" autocomplete="off" placeholder="SSO"
@keydown.enter.prevent="startSession(manualBadge)" />
<TouchKeypad @digit="manualBadge += $event"
@clear="manualBadge = ''"
@backspace="manualBadge = manualBadge.slice(0, -1)" />
<button class="btn btn-primary act-button"
:disabled="!manualBadge || busy"
@click="startSession(manualBadge)">
{{ busy ? 'Checking...' : 'Next' }}
</button>
</div>
</section>
<!-- the picker -->
<section v-else-if="step === 'pick'" class="kiosk-step kiosk-wide">
<p class="kiosk-greeting">{{ session.name }}</p>
<div v-if="session.yours.length" class="drive-group drive-group-yours">
<h2>Your drives</h2>
<div class="drive-list">
<button v-for="drive in session.yours" :key="drive.device_id"
class="drive-row" :disabled="busy"
@click="act(drive, 'checkin')">
<span class="drive-name">
{{ drive.device_id }}
<small v-if="drive.device_desc" class="drive-desc">{{ drive.device_desc }}</small>
</span>
<span class="drive-action return">Return</span>
</button>
</div>
</div>
<div class="drive-group">
<div class="drive-group-head">
<h2>Available</h2>
<!-- Typed, not tapped: this panel has a keyboard, and a cabinet of
thirty drives is faster to narrow than to read. -->
<div v-if="session.available.length" class="drive-filter">
<input ref="filterInput" v-model="drivefilter"
class="drive-filter-input"
autocomplete="off" spellcheck="false"
placeholder="Find a drive, a locker..." />
<button v-if="drivefilter" class="drive-filter-clear"
title="Clear" @click="drivefilter = ''">Clear</button>
</div>
</div>
<p v-if="!session.available.length" class="kiosk-hint">
No drives are free right now.
</p>
<p v-else-if="!availabledrives.length" class="kiosk-hint">
No free drive matches "{{ drivefilter }}".
</p>
<div v-else class="drive-list">
<button v-for="drive in availabledrives" :key="drive.device_id"
class="drive-row" :disabled="busy"
@click="act(drive, 'checkout')">
<span class="drive-name">
{{ drive.device_id }}
<small v-if="drive.device_desc" class="drive-desc">{{ drive.device_desc }}</small>
<small v-if="drive.locker_location">Locker {{ drive.locker_location }}</small>
</span>
<span class="drive-action take">Take</span>
</button>
</div>
</div>
<!-- Other people's drives are behind a tap, not on the main screen: a
passer-by should not read the whole cabinet's holders off a wall. -->
<button v-if="session.otherscount" class="btn btn-secondary others-button"
:disabled="busy" @click="loadOthers">
Returning a drive for somebody else?
({{ session.otherscount }} out with others)
</button>
</section>
<!-- somebody else's drives -->
<section v-else-if="step === 'others'" class="kiosk-step kiosk-wide">
<h2>Checked out to somebody else</h2>
<p class="kiosk-hint">
Returning one puts it back and records that YOU handed it in.
</p>
<div class="drive-list">
<button v-for="drive in others" :key="drive.device_id"
class="drive-row" :disabled="busy"
@click="act(drive, 'checkin', true)">
<span class="drive-name">
{{ drive.device_id }}
<small class="drive-desc">{{ drive.holdername || 'somebody else' }}</small>
</span>
<span class="drive-action return">Return</span>
</button>
</div>
<button class="btn btn-secondary act-button" @click="step = 'pick'">
Back
</button>
</section>
<!-- done -->
<section v-else-if="step === 'done'" class="kiosk-step">
<p class="kiosk-done">{{ doneMessage }}</p>
<button class="btn btn-primary act-button" @click="reset">Finish</button>
</section>
</template>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onBeforeUnmount, nextTick } from 'vue'
import api, { settingsApi, claimKioskToken, KIOSK_TOKEN_KEYS } from '@/api'
import TouchKeypad from '@/components/TouchKeypad.vue'
import { filterdrives } from '../drivefilter'
const step = ref('badge')
const badge = ref('')
const session = ref({ name: '', yours: [], available: [], otherscount: 0 })
const others = ref([])
const error = ref('')
const doneMessage = ref('')
const busy = ref(false)
const disabled = ref(false)
const manualBadge = ref('')
const entryInput = ref(null)
let resetTimer = null
onMounted(async () => {
claimKioskToken(KIOSK_TOKEN_KEYS['/usb/kiosk/'])
// The kiosk ships off. Reading the flag turns "everything 404s" into a screen
// that says so - a kiosk merely switched off must not look broken.
try {
const response = await settingsApi.get('usb_kiosk_enabled')
const value = String(response.data?.data?.value ?? '').toLowerCase()
disabled.value = !(value === 'true' || value === '1')
} catch (err) {
// An unreachable setting is not proof it is off; let the API decide.
disabled.value = false
}
focusEntry()
})
onBeforeUnmount(() => clearTimeout(resetTimer))
function focusEntry (event) {
const tag = event?.target?.tagName
if (tag === 'INPUT' || tag === 'SELECT' || tag === 'TEXTAREA'
|| tag === 'BUTTON' || tag === 'A') return
nextTick(() => entryInput.value?.focus())
}
// Narrowing the free drives. The matcher lives in its own module so it can be
// tested without mounting a kiosk.
const drivefilter = ref('')
const filterInput = ref(null)
// Focused as the picker opens, so somebody can simply start typing. The badge
// step owns the keyboard until then.
function focusFilter () {
nextTick(() => filterInput.value?.focus())
}
const availabledrives = computed(
() => filterdrives(session.value.available, drivefilter.value))
// Drive ids sort as TEXT everywhere else, which puts -10 before -3 and reads as
// a jumble on a wall panel. Compare the numeric runs numerically; falls back to
// plain text for ids that carry no digits.
const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' })
function byDeviceId (a, b) {
return collator.compare(a.device_id || '', b.device_id || '')
}
function messageFrom (err, fallback) {
return err.response?.data?.data?.error?.message || fallback
}
async function startSession (value) {
const scanned = (value || '').trim()
if (!scanned || busy.value) return
error.value = ''
busy.value = true
try {
const response = await api.post('/usb/kiosk/session', { badge: scanned })
const data = response.data.data
data.yours = (data.yours || []).sort(byDeviceId)
data.available = (data.available || []).sort(byDeviceId)
session.value = data
badge.value = scanned
manualBadge.value = ''
step.value = 'pick'
focusFilter()
} catch (sessionError) {
error.value = messageFrom(sessionError, 'Could not look that badge up')
} finally {
busy.value = false
focusEntry()
}
}
async function loadOthers () {
error.value = ''
busy.value = true
try {
const response = await api.post('/usb/kiosk/others', { badge: badge.value })
others.value = (response.data.data || []).sort(byDeviceId)
step.value = 'others'
} catch (othersError) {
error.value = messageFrom(othersError, 'Could not load those drives')
} finally {
busy.value = false
}
}
async function act (drive, action, forsomeoneelse = false) {
if (busy.value) return
error.value = ''
busy.value = true
try {
const body = { device_id: drive.device_id, badge: badge.value }
if (forsomeoneelse) body.forsomeoneelse = true
const response = await api.post(`/usb/kiosk/${action}`, body)
doneMessage.value = response.data?.message
|| (action === 'checkin' ? 'Drive returned' : 'Drive checked out')
step.value = 'done'
// Clear the screen for the next person rather than leaving a name up.
resetTimer = setTimeout(reset, 8000)
} catch (actError) {
error.value = messageFrom(actError, 'That did not go through')
// The list is now out of date either way - somebody else may have taken it.
await refresh()
} finally {
busy.value = false
}
}
async function refresh () {
try {
const response = await api.post('/usb/kiosk/session', { badge: badge.value })
const data = response.data.data
data.yours = (data.yours || []).sort(byDeviceId)
data.available = (data.available || []).sort(byDeviceId)
session.value = data
step.value = 'pick'
} catch (refreshError) {
reset()
}
}
function reset () {
clearTimeout(resetTimer)
step.value = 'badge'
badge.value = ''
session.value = { name: '', yours: [], available: [], otherscount: 0 }
others.value = []
error.value = ''
doneMessage.value = ''
manualBadge.value = ''
// The next person at the cabinet must not inherit somebody else's filter and
// conclude there are no drives left.
drivefilter.value = ''
focusEntry()
}
</script>
<style scoped>
/* Touch-sized throughout: a wall panel, often operated with gloves on.
Laid out to match the parts kiosk beside it - two kiosks on the same floor
that sit differently on their screens read as two different products. */
.usb-kiosk {
min-height: 100vh;
padding: 2rem;
background: var(--bg);
color: var(--text);
display: flex;
flex-direction: column;
align-items: center;
/* Centered as one block on a wall-mounted display rather than hugging the
top. `safe` so a short screen - or a long drive list - 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: 72rem;
display: flex;
justify-content: center;
align-items: center;
/* Title centered over the panel; Done 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-header h1 { margin: 0; font-size: 2rem; }
.kiosk-step {
display: flex;
flex-direction: column;
align-items: center;
gap: 1.25rem;
max-width: 40rem;
width: 100%;
}
/* The picker holds lists, so its rows stretch, and it is WIDER than the steps
that hold a single control - a cabinet of thirty drives is three or four
columns of one glance rather than a scroll. The steps that are just a keypad
or a message stay narrow, because stretching one input across a 24 inch panel
reads as a broken page.
It is also height-bounded: the lists absorb whatever space is left over and
scroll inside themselves, so the PAGE never scrolls and the heading saying
whose drives these are stays on screen. A fixed max-height cannot do this -
it either clips with room to spare or overflows once both groups are
present, which is exactly what it did. */
.kiosk-wide {
align-items: stretch;
max-width: 72rem;
max-height: calc(100vh - 9rem);
min-height: 0;
}
.kiosk-wide .drive-group {
display: flex;
flex-direction: column;
min-height: 0;
}
/* Your own drives size to their content - usually one or two - and the
available list absorbs what is left. Sharing the space evenly clipped a
single row of "Your drives" in half while the other list had room. */
.kiosk-wide .drive-group-yours { flex: 0 0 auto; }
.kiosk-wide .drive-group-yours .drive-list { max-height: 30vh; }
.kiosk-prompt { font-size: 1.5rem; text-align: center; margin: 0; }
.kiosk-greeting { font-size: 1.5rem; margin: 0; text-align: center; }
.kiosk-hint { color: var(--text-light); text-align: center; margin: 0; }
.kiosk-error {
padding: 0.9rem 1.1rem;
border: 1px solid var(--danger, #b3261e);
border-left-width: 5px;
border-radius: 4px;
color: var(--danger, #b3261e);
font-size: 1.1rem;
text-align: center;
}
.kiosk-done { font-size: 1.6rem; text-align: center; margin: 2rem 0; }
.drive-group { width: 100%; }
.drive-group h2 { font-size: 1.2rem; margin: 0 0 0.5rem; }
/* Heading and filter on one line: the filter belongs to the list under it, and
a row of its own would cost height the lists need. */
.drive-group-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
}
.drive-filter { display: flex; align-items: center; gap: 0.5rem; }
.drive-filter-input {
/* Touch-sized like everything else here, and wide enough for a locker name
rather than just a drive number. */
min-width: 16rem;
padding: 0.6rem 0.8rem;
font-size: 1.1rem;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg-card-solid);
color: var(--text);
}
.drive-filter-input:focus {
outline: none;
border-color: var(--primary);
}
.drive-filter-clear {
padding: 0.6rem 0.9rem;
font-size: 1rem;
border: 1px solid var(--border);
border-radius: 6px;
background: transparent;
color: var(--text);
cursor: pointer;
}
/* As many columns as the panel has room for - four on a wall display, one on a
small screen - so a cabinet of a dozen drives is one glance rather than a
scroll. The list itself scrolls when it has to; the PAGE must not, or the
person loses the heading telling them whose drives these are. */
.drive-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr));
gap: 0.6rem;
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
padding-right: 0.2rem;
}
.drive-row {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.9rem 1rem;
border: 1px solid var(--border);
border-radius: 0.75rem;
background: var(--bg-card);
color: var(--text);
font-size: 1.05rem;
text-align: left;
cursor: pointer;
}
.drive-row:disabled { opacity: 0.5; }
/* The name has to YIELD. A flex item defaults to min-width:auto, so a long
label kept its intrinsic width and shoved Take or Return out through the side
of the card - a drive nobody could take. `anywhere` breaks the labels that
carry no spaces at all, which a serial-style name often does not. */
.drive-name {
display: flex;
flex-direction: column;
gap: 0.15rem;
flex: 1 1 auto;
min-width: 0;
overflow-wrap: anywhere;
}
.drive-name small { color: var(--text-light); font-size: 0.85rem; }
/* Two lines of description is enough to tell two drives apart; more just makes
one card tall and every card in its row tall with it. The locker line below
is never clipped - it is how somebody finds the thing. */
.drive-desc {
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.drive-action {
flex: 0 0 auto;
padding: 0.5rem 1.1rem;
border-radius: 4px;
font-weight: 600;
font-size: 1rem;
}
.take { background: var(--primary, #2f6fed); color: #fff; }
.return { background: var(--warning, #b26a00); color: #fff; }
.others-button { width: 100%; padding: 0.9rem; margin-top: 0.5rem; }
.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::placeholder {
color: var(--text-light);
font-weight: 400;
font-size: 1.4rem;
}
.act-button {
font-size: 1.4rem;
padding: 0.85rem 0;
width: 16.9rem;
border-radius: 0.75rem;
}
</style>