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
511 lines
17 KiB
Vue
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>
|