Files
shopdb-flask/plugins/usb/frontend/views/USBKiosk.vue
cproudlock 7c47ae1f7c USB kiosk: take and return a drive without a ShopDB login
The people who actually take USB drives are standing at the cabinet, and most
of them have no account here. Check-out and check-in were gated behind a login
that was not doing much anyway: both modes already attribute the action to a
scanned BADGE rather than to the signed-in user, so the login was gating the
screen, not the record.

/usb-kiosk is a full-screen touch page modelled on the parts kiosk, off unless
a site sets usb_kiosk_enabled - every route 404s until then, rather than 403,
so a disabled kiosk does not advertise that there is a door here. It works
against the self-hosted tables or an existing cmmc_usb database.

THE LIST IS THE TRADE. These drives carry no barcode, so unlike the parts kiosk
there is nothing to present and the screen has to offer a list - which exposes
more than a scan-first design, where you had to already hold a drive to learn
anything. So the list is not anonymous: an SSO comes first and must resolve to
a real person before any drive is named, which is the same bar taking a drive
already had to clear. It is a POST so the badge stays out of proxy access logs.
The main screen shows the caller's own drives, what is free, and a COUNT of
everything else; holders of other people's drives appear only behind a second
deliberate tap, so nobody reads the whole cabinet off a wall panel in passing.

A badge that resolves to no employee is REFUSED, unlike the signed-in route
which creates the user row. A checkout nobody can trace to a person defeats the
tracking, and at a kiosk there is no operator standing there to vouch for it.

Returning somebody else's drive asks first and then records the badge that was
SCANNED, so the log says who actually handed it back. That is the honest record
and it needs no change to a schema this app does not own. Refusing a mismatch
outright is a setting but not the default: a drive left behind by somebody on
leave would otherwise be unreturnable at the kiosk, which just moves the problem
to a person with a login.

The authz sweep caught these writes, twice - once for the two mutations and
again for the two list endpoints. Both are exempted with the reasoning written
out beside the parts-kiosk entry rather than silenced. The list endpoints are
POSTs that change nothing.

Verified by driving the real page: SSO on the keypad, tap Take, database moves.
2026-08-27 08:14:02 -04:00

328 lines
10 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">
<h2>Your drives</h2>
<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">{{ drive.device_desc }}</small>
</span>
<span class="drive-action return">Return</span>
</button>
</div>
<div class="drive-group">
<h2>Available</h2>
<p v-if="!session.available.length" class="kiosk-hint">
No drives are free right now.
</p>
<button v-for="drive in session.available" :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">{{ 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>
<!-- 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>
<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>{{ drive.holdername || 'somebody else' }}</small>
</span>
<span class="drive-action return">Return</span>
</button>
<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, onMounted, onBeforeUnmount, nextTick } from 'vue'
import api, { settingsApi } from '@/api'
import TouchKeypad from '@/components/TouchKeypad.vue'
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 () => {
// 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())
}
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 })
session.value = response.data.data
badge.value = scanned
manualBadge.value = ''
step.value = 'pick'
} 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 || []
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 })
session.value = response.data.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 = ''
focusEntry()
}
</script>
<style scoped>
/* Touch-sized throughout: a wall panel, often operated with gloves on. */
.usb-kiosk {
min-height: 100vh;
padding: 1.5rem;
background: var(--bg);
color: var(--text);
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.kiosk-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.kiosk-header h1 { margin: 0; font-size: 2rem; }
.kiosk-step {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
max-width: 34rem;
width: 100%;
margin: 0 auto;
}
.kiosk-wide { max-width: 46rem; align-items: stretch; }
.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(--textmuted); 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; }
.drive-row {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 1rem 1.2rem;
margin-bottom: 0.6rem;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--surface, transparent);
color: var(--text);
font-size: 1.2rem;
text-align: left;
cursor: pointer;
}
.drive-row:disabled { opacity: 0.5; }
.drive-name { display: flex; flex-direction: column; gap: 0.15rem; }
.drive-name small { color: var(--textmuted); font-size: 0.85rem; }
.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 {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.9rem;
}
.entry-input {
width: 100%;
padding: 0.8rem 1rem;
font-size: 1.6rem;
text-align: center;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--surface, transparent);
color: var(--text);
}
.act-button { width: 100%; padding: 1rem; font-size: 1.25rem; }
</style>