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.
This commit is contained in:
cproudlock
2026-08-27 08:13:31 -04:00
parent e48505dcc7
commit 7c47ae1f7c
8 changed files with 1203 additions and 0 deletions

View File

@@ -131,6 +131,7 @@ const TITLE_SPELLINGS = {
'knowledgebase': 'Knowledge Base',
'printedparts': '3D Printed Parts',
'parts-kiosk': 'Parts Kiosk',
'usb-kiosk': 'USB Kiosk',
'tv': 'TV Slideshow',
'shopfloor': 'Shopfloor Dashboard',
'measuringtools': 'Measuring Tools',

View File

@@ -721,3 +721,350 @@ def list_active_checkouts():
conn.close()
return success_response(data)
# =============================================================================
# Kiosk: UNAUTHENTICATED check-out / check-in
# =============================================================================
# The bar these have to meet, same as the parts kiosk: they can move a device
# between checked-out and checked-in and NOTHING else. No create, no edit, no
# retire, no browsing the fleet, no reading who holds what without presenting
# the device. Identity is the scanned badge, resolved server-side; the client
# never asserts who it is.
#
# Off unless a site turns it on. An unauthenticated write is a decision about
# the door, not about the person walking through it.
def _kiosk_setting(key, default=False):
row = Setting.query.filter_by(key=key).first()
if row is None or row.value is None or row.value == '':
return default
return str(row.value).strip().lower() in ('true', '1', 'yes', 'on')
def _kiosk_enabled():
return _kiosk_setting('usb_kiosk_enabled', False)
def _kiosk_disabled_response():
# 404, not 403: when the kiosk is off these routes should not read as
# "there is a door here and you may not use it".
return error_response(ErrorCodes.NOT_FOUND, 'Not found', http_code=404)
def _kiosk_badge(data):
"""The scanned badge, or an error response. Never trusts a client name."""
badge = (data.get('badge') or '').strip()
if not badge:
return None, error_response(ErrorCodes.VALIDATION_ERROR,
'Scan your badge')
return badge, None
@usb_bp.route('/kiosk/device/<device_id>', methods=['GET'])
def kiosk_device(device_id):
"""What the kiosk shows after a device is scanned.
Deliberately narrow: the description, whether it is out, and the NAME of
whoever holds it - which the return-for-someone-else confirm has to show, or
the person standing there cannot tell whether they are doing the right
thing. No badge number, no history, no contact details.
"""
if not _kiosk_enabled():
return _kiosk_disabled_response()
if _usb_selfhosted():
return selfhosted.kiosk_device(device_id)
conn = cmmc_usb_connection()
try:
with conn.cursor() as cur:
device = _fetch_device(cur, device_id)
if not device:
return error_response(ErrorCodes.NOT_FOUND,
'No device matches that code',
http_code=404)
holder = ''
if device.get('status') == _STATUS_CHECKED_OUT:
holder = _kiosk_holder_name(cur, device_id)
finally:
conn.close()
return success_response({
'device_id': device.get('device_id'),
'device_desc': device.get('device_desc'),
'status': device.get('status'),
'locker_location': device.get('locker_location'),
'holdername': holder,
})
def _kiosk_holder_badge(cur, device_id):
"""Badge on the most recent check-out for this device, or ''."""
cur.execute(
'SELECT badge_number FROM checkinoutlog '
"WHERE device_id = %s AND action = 'check-out' "
'ORDER BY timestamp DESC, log_id DESC LIMIT 1',
(device_id,),
)
row = cur.fetchone()
return (row.get('badge_number') or '').strip() if row else ''
def _kiosk_holder_name(cur, device_id):
badge = _kiosk_holder_badge(cur, device_id)
return _resolve_badge_name(cur, badge) if badge else ''
@usb_bp.route('/kiosk/session', methods=['POST'])
def kiosk_session():
"""What one badge may do right now. Body: {badge}.
The drives here carry no barcode, so the kiosk cannot ask somebody to
present one - it has to offer a LIST. That is a real widening: before, you
had to already hold a drive to learn anything. So the list is not anonymous.
It costs a badge that resolves to a real person, which is the same bar
check-out itself has to clear, and it is a POST so the badge does not end up
in a proxy access log the way a query string would.
Returns the caller's own drives (to return), what is free (to take), and a
COUNT of everything else - names for those are a separate, deliberate step,
so a passer-by does not read the whole cabinet's holders off a wall screen.
"""
if not _kiosk_enabled():
return _kiosk_disabled_response()
data = request.get_json() or {}
if _usb_selfhosted():
return selfhosted.kiosk_session(data)
badge, failure = _kiosk_badge(data)
if failure:
return failure
conn = cmmc_usb_connection()
try:
with conn.cursor() as cur:
name = _resolve_badge_name(cur, badge)
if not name:
return error_response(
ErrorCodes.VALIDATION_ERROR,
'Badge not recognized - see IT',
http_code=422)
cur.execute(
'SELECT ' + _DEVICE_COLS + ' FROM devices '
'WHERE status IN (%s, %s) ORDER BY device_id',
(_STATUS_CHECKED_IN, _STATUS_CHECKED_OUT),
)
rows = cur.fetchall() or []
yours, available, others = [], [], 0
for row in rows:
entry = {
'device_id': row.get('device_id'),
'device_desc': row.get('device_desc'),
'locker_location': row.get('locker_location'),
}
if row.get('status') == _STATUS_CHECKED_IN:
available.append(entry)
elif _kiosk_holder_badge(cur, row.get('device_id')) == badge:
yours.append(entry)
else:
others += 1
finally:
conn.close()
return success_response({'name': name, 'yours': yours,
'available': available, 'otherscount': others})
@usb_bp.route('/kiosk/others', methods=['POST'])
def kiosk_others():
"""Drives checked out to OTHER people, with holder names. Body: {badge}.
Behind its own tap, and only when returning for somebody else is allowed at
all. Somebody has to be able to put back a drive left on a bench by a person
who is on leave - but the names that makes visible should not be the first
thing on a shopfloor screen.
"""
if not _kiosk_enabled():
return _kiosk_disabled_response()
if not _kiosk_setting('usb_kiosk_allow_return_for_others', True):
return _kiosk_disabled_response()
data = request.get_json() or {}
if _usb_selfhosted():
return selfhosted.kiosk_others(data)
badge, failure = _kiosk_badge(data)
if failure:
return failure
conn = cmmc_usb_connection()
try:
with conn.cursor() as cur:
if not _resolve_badge_name(cur, badge):
return error_response(ErrorCodes.VALIDATION_ERROR,
'Badge not recognized - see IT',
http_code=422)
cur.execute(
'SELECT ' + _DEVICE_COLS + ' FROM devices WHERE status = %s '
'ORDER BY device_id',
(_STATUS_CHECKED_OUT,),
)
out = []
for row in cur.fetchall() or []:
holderbadge = _kiosk_holder_badge(cur, row.get('device_id'))
if holderbadge == badge:
continue
out.append({
'device_id': row.get('device_id'),
'device_desc': row.get('device_desc'),
'locker_location': row.get('locker_location'),
'holdername': _resolve_badge_name(cur, holderbadge),
})
finally:
conn.close()
return success_response(out)
@usb_bp.route('/kiosk/checkout', methods=['POST'])
def kiosk_checkout():
"""Check a device out at the kiosk. Body: {device_id, badge}."""
if not _kiosk_enabled():
return _kiosk_disabled_response()
data = request.get_json() or {}
if _usb_selfhosted():
return selfhosted.kiosk_checkout(data)
device_id = (data.get('device_id') or '').strip()
badge, failure = _kiosk_badge(data)
if failure:
return failure
conn = cmmc_usb_connection()
try:
with conn.cursor() as cur:
device = _fetch_device(cur, device_id)
if not device:
return error_response(ErrorCodes.NOT_FOUND,
'No device matches that code',
http_code=404)
if device.get('status') == _STATUS_RETIRED:
return error_response(ErrorCodes.VALIDATION_ERROR,
'That device is retired - see IT')
if device.get('status') == _STATUS_CHECKED_OUT:
holder = _kiosk_holder_name(cur, device_id)
return error_response(
ErrorCodes.CONFLICT,
'That device is already checked out{}'.format(
' to {}'.format(holder) if holder else ''),
http_code=409)
# An unresolvable badge is REFUSED here, unlike the signed-in route
# which creates the user row. A checkout nobody can trace to a
# person is the one thing this tracking exists to prevent, and at a
# kiosk there is no signed-in operator to vouch for it.
name = _resolve_badge_name(cur, badge)
if not name:
return error_response(
ErrorCodes.VALIDATION_ERROR,
'Badge not recognized - see IT to check this device out',
http_code=422)
cur.execute(
'INSERT INTO checkinoutlog '
'(badge_number, device_id, action, timestamp, locker_location) '
"VALUES (%s, %s, 'check-out', %s, %s)",
(badge, device_id, _now(), device.get('locker_location')),
)
cur.execute(
'UPDATE devices SET status = %s WHERE device_id = %s',
(_STATUS_CHECKED_OUT, device_id),
)
conn.commit()
finally:
conn.close()
return success_response({'device_id': device_id, 'holdername': name},
message='{} checked out to {}'.format(
device_id, name))
@usb_bp.route('/kiosk/checkin', methods=['POST'])
def kiosk_checkin():
"""Check a device in at the kiosk. Body: {device_id, badge, forsomeoneelse}.
Holder by default. A mismatch is refused with the holder's name and a flag
the screen uses to offer "returning it for them", because the alternative -
a drive whose owner is on leave that literally cannot be returned at the
kiosk - just moves the problem to somebody with a login.
The badge RECORDED is the one that was scanned: the log says who actually
handed the device back, which is the honest record and needs no change to a
schema this app does not own.
"""
if not _kiosk_enabled():
return _kiosk_disabled_response()
data = request.get_json() or {}
if _usb_selfhosted():
return selfhosted.kiosk_checkin(data)
device_id = (data.get('device_id') or '').strip()
badge, failure = _kiosk_badge(data)
if failure:
return failure
forsomeoneelse = bool(data.get('forsomeoneelse'))
conn = cmmc_usb_connection()
try:
with conn.cursor() as cur:
device = _fetch_device(cur, device_id)
if not device:
return error_response(ErrorCodes.NOT_FOUND,
'No device matches that code',
http_code=404)
if device.get('status') != _STATUS_CHECKED_OUT:
return error_response(ErrorCodes.VALIDATION_ERROR,
'That device is not checked out')
name = _resolve_badge_name(cur, badge)
if not name:
return error_response(
ErrorCodes.VALIDATION_ERROR,
'Badge not recognized - see IT to return this device',
http_code=422)
holderbadge = _kiosk_holder_badge(cur, device_id)
if holderbadge and holderbadge != badge:
if not _kiosk_setting('usb_kiosk_allow_return_for_others', True):
return error_response(
ErrorCodes.FORBIDDEN,
'That device is checked out to somebody else - they '
'need to return it, or see IT',
http_code=403)
if not forsomeoneelse:
holdername = _resolve_badge_name(cur, holderbadge)
return error_response(
ErrorCodes.CONFLICT,
'That device is checked out to {}. Confirm you are '
'returning it for them.'.format(holdername or 'somebody else'),
details={'needsconfirm': True,
'holdername': holdername},
http_code=409)
cur.execute(
'INSERT INTO checkinoutlog '
'(badge_number, device_id, action, timestamp, locker_location) '
"VALUES (%s, %s, 'check-in', %s, %s)",
(badge, device_id, _now(), device.get('locker_location')),
)
cur.execute(
'UPDATE devices SET status = %s WHERE device_id = %s',
(_STATUS_CHECKED_IN, device_id),
)
conn.commit()
finally:
conn.close()
return success_response({'device_id': device_id},
message='{} checked in'.format(device_id))

View File

@@ -274,3 +274,181 @@ def list_checkouts(request, active_only):
event['device_status'] = _status(checkout.device) if checkout.device else None
data.append(event)
return success_response(data)
# ---------------------------------------------------------------------------
# Kiosk (self-hosted mode). Same contract as the external path in routes.py:
# move a device between checked-out and checked-in, attributed to a badge that
# resolved to a real person, and nothing else.
# ---------------------------------------------------------------------------
def kiosk_device(device_id):
device = _find(device_id)
if not device:
return error_response(ErrorCodes.NOT_FOUND,
'No device matches that code', http_code=404)
return success_response({
'device_id': device.serialnumber,
'device_desc': device.label or device.productname or '',
'status': _status(device),
'locker_location': device.storagelocation,
# Name only. The kiosk needs it for the return-for-someone-else
# confirm; it does not need their badge.
'holdername': device.currentusername or '' if device.ischeckedout else '',
})
def kiosk_session(data):
"""See routes.kiosk_session for why the list costs a valid badge."""
badge = (data.get('badge') or '').strip()
if not badge:
return error_response(ErrorCodes.VALIDATION_ERROR, 'Scan your badge')
name = _resolve_name(badge)
if not name:
return error_response(ErrorCodes.VALIDATION_ERROR,
'Badge not recognized - see IT', http_code=422)
yours, available, others = [], [], 0
for device in USBDevice.query.filter_by(isactive=True).order_by(
USBDevice.serialnumber).all():
entry = {
'device_id': device.serialnumber,
'device_desc': device.label or device.productname or '',
'locker_location': device.storagelocation,
}
if not device.ischeckedout:
available.append(entry)
elif (device.currentuserid or '').strip() == badge:
yours.append(entry)
else:
others += 1
return success_response({'name': name, 'yours': yours,
'available': available, 'otherscount': others})
def kiosk_others(data):
badge = (data.get('badge') or '').strip()
if not badge:
return error_response(ErrorCodes.VALIDATION_ERROR, 'Scan your badge')
if not _resolve_name(badge):
return error_response(ErrorCodes.VALIDATION_ERROR,
'Badge not recognized - see IT', http_code=422)
out = []
for device in USBDevice.query.filter_by(isactive=True, ischeckedout=True
).order_by(USBDevice.serialnumber).all():
if (device.currentuserid or '').strip() == badge:
continue
out.append({
'device_id': device.serialnumber,
'device_desc': device.label or device.productname or '',
'locker_location': device.storagelocation,
'holdername': device.currentusername or '',
})
return success_response(out)
def kiosk_checkout(data):
device_id = (data.get('device_id') or '').strip()
badge = (data.get('badge') or '').strip()
if not badge:
return error_response(ErrorCodes.VALIDATION_ERROR, 'Scan your badge')
device = _find(device_id)
if not device:
return error_response(ErrorCodes.NOT_FOUND,
'No device matches that code', http_code=404)
if not device.isactive:
return error_response(ErrorCodes.VALIDATION_ERROR,
'That device is retired - see IT')
if device.ischeckedout:
return error_response(
ErrorCodes.CONFLICT,
'That device is already checked out{}'.format(
' to {}'.format(device.currentusername)
if device.currentusername else ''),
http_code=409)
# Refused rather than recorded anonymously - see the note on the external
# path. A checkout nobody can trace to a person defeats the tracking.
name = _resolve_name(badge)
if not name:
return error_response(
ErrorCodes.VALIDATION_ERROR,
'Badge not recognized - see IT to check this device out',
http_code=422)
now = datetime.now(timezone.utc).replace(tzinfo=None)
db.session.add(USBCheckout(usbdeviceid=device.usbdeviceid, sso=badge,
checkoutname=name, checkouttime=now))
device.ischeckedout = True
device.currentuserid = badge
device.currentusername = name
device.currentcheckoutdate = now
db.session.commit()
return success_response({'device_id': device.serialnumber,
'holdername': name},
message='{} checked out to {}'.format(
device.serialnumber, name))
def kiosk_checkin(data):
device_id = (data.get('device_id') or '').strip()
badge = (data.get('badge') or '').strip()
if not badge:
return error_response(ErrorCodes.VALIDATION_ERROR, 'Scan your badge')
forsomeoneelse = bool(data.get('forsomeoneelse'))
device = _find(device_id)
if not device:
return error_response(ErrorCodes.NOT_FOUND,
'No device matches that code', http_code=404)
if not device.ischeckedout:
return error_response(ErrorCodes.VALIDATION_ERROR,
'That device is not checked out')
name = _resolve_name(badge)
if not name:
return error_response(
ErrorCodes.VALIDATION_ERROR,
'Badge not recognized - see IT to return this device',
http_code=422)
holderbadge = (device.currentuserid or '').strip()
if holderbadge and holderbadge != badge:
if not _kiosk_flag('usb_kiosk_allow_return_for_others', True):
return error_response(
ErrorCodes.FORBIDDEN,
'That device is checked out to somebody else - they need to '
'return it, or see IT',
http_code=403)
if not forsomeoneelse:
return error_response(
ErrorCodes.CONFLICT,
'That device is checked out to {}. Confirm you are returning '
'it for them.'.format(device.currentusername or 'somebody else'),
details={'needsconfirm': True,
'holdername': device.currentusername or ''},
http_code=409)
open_checkout = (USBCheckout.query
.filter_by(usbdeviceid=device.usbdeviceid, checkintime=None)
.order_by(USBCheckout.checkouttime.desc()).first())
if open_checkout:
open_checkout.checkintime = datetime.now(timezone.utc).replace(tzinfo=None)
device.ischeckedout = False
device.currentuserid = None
device.currentusername = None
device.currentcheckoutdate = None
db.session.commit()
return success_response({'device_id': device.serialnumber},
message='{} checked in'.format(device.serialnumber))
def _kiosk_flag(key, default):
from shopdb.api import Setting
row = Setting.query.filter_by(key=key).first()
if row is None or row.value is None or row.value == '':
return default
return str(row.value).strip().lower() in ('true', '1', 'yes', 'on')

View File

@@ -29,6 +29,16 @@ export default [
]
export const toplevel = [
{
// Touch kiosk for taking and returning USB drives: scan the drive, scan a
// badge. Full-screen and OUTSIDE AppLayout, and deliberately not behind
// requiresAuth - the whole point is that a guest at the cabinet can use it.
// The API refuses every kiosk route unless usb_kiosk_enabled is on.
path: '/usb-kiosk',
name: 'usb-kiosk',
component: () => import('./views/USBKiosk.vue'),
meta: { plugin: 'usb' }
},
{
path: '/print/usb-labels',
name: 'print-usb-labels',

View File

@@ -0,0 +1,327 @@
<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>

View File

@@ -70,6 +70,44 @@ class USBPlugin(BasePlugin):
'docs': 'plugins/usb/README.md',
}
def get_settings_defaults(self) -> List[Dict]:
"""Kiosk settings.
The kiosk is an UNAUTHENTICATED write, so it ships off and a site turns
it on deliberately. Opening that door is a decision about the door.
"""
return [
{
'key': 'usb_kiosk_enabled',
'value': 'false',
'valuetype': 'boolean',
'category': 'usb',
# Public: the kiosk page runs logged out and has to be able to
# tell "turned off" from "broken" before anyone signs in.
'public': True,
'description': 'Allow check-out and check-in from the logged-out '
'USB kiosk screen (/usb-kiosk). Off by default: the '
'kiosk writes without a login, attributing every '
'action to a scanned badge instead. It can only move '
'a device between checked-out and checked-in - it '
'cannot add, edit, retire or list devices.',
},
{
'key': 'usb_kiosk_allow_return_for_others',
'value': 'true',
'valuetype': 'boolean',
'category': 'usb',
'public': True,
'description': 'At the kiosk, allow somebody to return a device '
'checked out to a DIFFERENT person after confirming. '
'The returning badge is what gets recorded, so the '
'log shows who actually handed it back. Turn this '
'off to refuse any mismatch outright - a device left '
'behind by someone on leave then needs a signed-in '
'user to return it.',
},
]
def get_config_schema(self) -> List[Dict]:
"""CMMC USB check-in/out database connection. Host/name/user are settings
the wizard can edit; the password stays in .env (emitted, not stored)."""

View File

@@ -51,6 +51,30 @@ EXEMPT_ENDPOINTS = {'knowledgebase.track_click', 'users.update_user',
# docs/proposals/printedparts-plugin.md; justification in
# the plugin README.
'printedparts.kiosk_take',
# Deliberately open kiosk writes, same bar as the parts
# kiosk: they can ONLY move a USB device between
# checked-out and checked-in, and only when a site has set
# usb_kiosk_enabled (off by default - every one of these is
# a 404 until then). Identity is a scanned badge resolved
# server-side, and a badge that resolves to no employee is
# refused rather than recorded, so nothing here can create
# an untraceable checkout. The people who take drives stand
# at the cabinet and mostly have no account, and both modes
# already attributed the action to the badge rather than to
# the signed-in user - the login was gating the screen, not
# the record. Covered by tests/test_plugins/test_usb_kiosk.py.
'usb.kiosk_checkout',
'usb.kiosk_checkin',
# The kiosk's two list endpoints. POSTs that CHANGE NOTHING:
# they are reads, shaped as POSTs so the badge stays out of
# query strings and proxy access logs. Same gate as the
# writes above - 404 unless usb_kiosk_enabled, and they name
# no drive until the badge resolves to a real person. The
# drives have no barcode, so a list is the only way to pick
# one; kiosk_others additionally 404s when returning for
# somebody else is switched off.
'usb.kiosk_session',
'usb.kiosk_others',
# Redeeming a shared secret. The recipient usually has no
# account here, so the unguessable 128-bit token IS the
# authorisation - gating this on a role would make the

View File

@@ -0,0 +1,278 @@
"""The USB kiosk: an UNAUTHENTICATED write, so the guards are the feature.
The kiosk exists so somebody at the drive cabinet - with no ShopDB login - can
take a drive and put it back, with the action attributed to their badge instead
of to a signed-in operator. Everything here is about the bar that opens the door
only as far as it has to: it can move a device between checked-out and
checked-in and nothing else, it refuses a badge it cannot put a name to, and it
does not exist at all until a site turns it on.
Self-hosted mode. The external cmmc_usb path is the same contract against raw
SQL and needs that database to exercise.
"""
import pytest
from shopdb.core.models import Setting
@pytest.fixture
def kiosk(db):
"""Self-hosted USB, kiosk ON, one employee and one available drive."""
from plugins.employees.models import DirectoryEmployee
from plugins.usb.models import USBDevice
db.session.add(Setting(key='usb_directory_mode', value='selfhosted',
valuetype='string', category='site'))
db.session.add(Setting(key='usb_kiosk_enabled', value='true',
valuetype='boolean', category='usb'))
db.session.add(DirectoryEmployee(sso=270015376, firstname='Dana',
lastname='Reyes'))
db.session.add(DirectoryEmployee(sso=270099999, firstname='Sam',
lastname='Okafor'))
db.session.add(USBDevice(serialnumber='USB-1', label='Blue Kingston',
ischeckedout=False))
db.session.commit()
return db
def _setting(db, key, value):
row = Setting.query.filter_by(key=key).first()
if row is None:
db.session.add(Setting(key=key, value=value, valuetype='boolean',
category='usb'))
else:
row.value = value
db.session.commit()
def _out(client, badge='270015376', **extra):
return client.post('/api/usb/kiosk/checkout',
json=dict({'device_id': 'USB-1', 'badge': badge}, **extra))
def _in(client, badge='270015376', **extra):
return client.post('/api/usb/kiosk/checkin',
json=dict({'device_id': 'USB-1', 'badge': badge}, **extra))
# --- the door itself --------------------------------------------------------
def test_the_kiosk_does_not_exist_until_a_site_turns_it_on(client, kiosk):
"""Default off, and 404 rather than 403.
An unauthenticated write is a decision about the door. A site that has not
made that decision should not have these routes answering at all, and a
disabled kiosk should not advertise that there is a door here.
"""
_setting(kiosk, 'usb_kiosk_enabled', 'false')
assert client.get('/api/usb/kiosk/device/USB-1').status_code == 404
assert _out(client).status_code == 404
assert _in(client).status_code == 404
def test_it_ships_off(app):
"""The declared default is what a fresh site gets."""
from plugins.usb.plugin import USBPlugin
defaults = {s['key']: s for s in USBPlugin().get_settings_defaults()}
assert defaults['usb_kiosk_enabled']['value'] == 'false'
def test_no_login_is_required(client, kiosk):
"""The whole point: no Authorization header anywhere in this file."""
response = client.get('/api/usb/kiosk/device/USB-1')
assert response.status_code == 200
assert response.get_json()['data']['device_id'] == 'USB-1'
def test_the_kiosk_cannot_create_edit_or_delete(client, kiosk):
"""It moves a device between two states. It is not an admin surface."""
assert client.post('/api/usb/kiosk/device/USB-1').status_code in (404, 405)
assert client.delete('/api/usb/kiosk/device/USB-1').status_code in (404, 405)
# And the authenticated write routes are still gated.
assert client.post('/api/usb/USB-1/checkout',
json={'badge': '270015376'}).status_code == 401
# --- badges -----------------------------------------------------------------
def test_a_badge_it_cannot_name_is_refused(client, kiosk):
"""A checkout nobody can trace to a person defeats the tracking.
The signed-in route creates the user row for an unknown badge; the kiosk
must not, because there is no operator standing there to vouch for it.
"""
response = _out(client, badge='999999999')
assert response.status_code == 422
assert 'not recognized' in response.get_json()['data']['error']['message']
from plugins.usb.models import USBDevice
assert USBDevice.query.filter_by(serialnumber='USB-1').first().ischeckedout is False
def test_a_missing_badge_is_refused(client, kiosk):
assert client.post('/api/usb/kiosk/checkout',
json={'device_id': 'USB-1'}).status_code == 400
# --- taking one -------------------------------------------------------------
def test_taking_a_drive_records_who_took_it(client, kiosk):
from plugins.usb.models import USBDevice
response = _out(client)
assert response.status_code == 200, response.get_json()
device = USBDevice.query.filter_by(serialnumber='USB-1').first()
assert device.ischeckedout is True
assert device.currentuserid == '270015376'
assert device.currentusername == 'Dana Reyes'
def test_a_drive_already_out_cannot_be_taken_again(client, kiosk):
_out(client)
response = _out(client, badge='270099999')
assert response.status_code == 409
assert 'Dana Reyes' in response.get_json()['data']['error']['message']
def test_an_unknown_code_is_a_404(client, kiosk):
response = client.post('/api/usb/kiosk/checkout',
json={'device_id': 'NOPE', 'badge': '270015376'})
assert response.status_code == 404
# --- putting one back -------------------------------------------------------
def test_the_holder_returns_their_own_drive(client, kiosk):
from plugins.usb.models import USBDevice
_out(client)
assert _in(client).status_code == 200
device = USBDevice.query.filter_by(serialnumber='USB-1').first()
assert device.ischeckedout is False
assert device.currentuserid is None
def test_returning_somebody_elses_drive_asks_first(client, kiosk):
"""Not a silent success: the record is about to say they handed it back."""
from plugins.usb.models import USBDevice
_out(client)
response = _in(client, badge='270099999')
assert response.status_code == 409
details = response.get_json()['data']['error']['details']
assert details['needsconfirm'] is True
assert details['holdername'] == 'Dana Reyes'
assert USBDevice.query.filter_by(serialnumber='USB-1').first().ischeckedout is True
def test_confirming_returns_it_for_them(client, kiosk):
"""A drive left on a bench by somebody on leave still gets put back.
Refusing outright just moves the problem to a person with a login.
"""
from plugins.usb.models import USBDevice
_out(client)
response = _in(client, badge='270099999', forsomeoneelse=True)
assert response.status_code == 200, response.get_json()
assert USBDevice.query.filter_by(serialnumber='USB-1').first().ischeckedout is False
def test_a_site_can_refuse_returns_for_others_outright(client, kiosk):
_setting(kiosk, 'usb_kiosk_allow_return_for_others', 'false')
_out(client)
response = _in(client, badge='270099999', forsomeoneelse=True)
assert response.status_code == 403
def test_a_drive_that_is_not_out_cannot_be_returned(client, kiosk):
assert _in(client).status_code == 400
# --- what the screen is allowed to know -------------------------------------
def test_the_lookup_names_the_holder_but_not_their_badge(client, kiosk):
"""The confirm step has to show whose it is; it does not need their SSO."""
_out(client)
data = client.get('/api/usb/kiosk/device/USB-1').get_json()['data']
assert data['holdername'] == 'Dana Reyes'
assert '270015376' not in str(data)
# --- the picker -------------------------------------------------------------
# The drives carry no barcode, so the kiosk cannot ask somebody to present one -
# it has to offer a LIST. That is a real widening of what a logged-out screen
# exposes, so the list is not anonymous: it costs a badge that resolves to a
# real person, the same bar taking a drive already had to clear.
def _session(client, badge='270015376'):
return client.post('/api/usb/kiosk/session', json={'badge': badge})
def test_the_device_list_costs_a_valid_badge(client, kiosk):
assert client.post('/api/usb/kiosk/session', json={}).status_code == 400
assert _session(client, badge='999999999').status_code == 422
def test_the_list_is_not_reachable_when_the_kiosk_is_off(client, kiosk):
_setting(kiosk, 'usb_kiosk_enabled', 'false')
assert _session(client).status_code == 404
assert client.post('/api/usb/kiosk/others',
json={'badge': '270015376'}).status_code == 404
def test_the_session_separates_yours_from_available(client, kiosk):
from plugins.usb.models import USBDevice
kiosk.session.add(USBDevice(serialnumber='USB-2', label='Second',
ischeckedout=False))
kiosk.session.commit()
_out(client) # 270015376 takes USB-1
data = _session(client).get_json()['data']
assert data['name'] == 'Dana Reyes'
assert [d['device_id'] for d in data['yours']] == ['USB-1']
assert [d['device_id'] for d in data['available']] == ['USB-2']
assert data['otherscount'] == 0
def test_somebody_elses_drive_is_a_count_not_a_name(client, kiosk):
"""A passer-by should not read the cabinet's holders off a wall screen."""
_out(client) # Dana takes USB-1
data = _session(client, badge='270099999').get_json()['data']
assert data['yours'] == []
assert data['otherscount'] == 1
assert 'Dana' not in str(data)
def test_names_appear_only_on_the_deliberate_second_step(client, kiosk):
_out(client)
rows = client.post('/api/usb/kiosk/others',
json={'badge': '270099999'}).get_json()['data']
assert [r['device_id'] for r in rows] == ['USB-1']
assert rows[0]['holdername'] == 'Dana Reyes'
def test_the_others_list_is_gone_when_returns_for_others_are_refused(client, kiosk):
"""No point showing a list of drives the kiosk will not let anyone return."""
_setting(kiosk, 'usb_kiosk_allow_return_for_others', 'false')
_out(client)
assert client.post('/api/usb/kiosk/others',
json={'badge': '270099999'}).status_code == 404
def test_a_retired_drive_is_not_offered(client, kiosk):
from plugins.usb.models import USBDevice
device = USBDevice.query.filter_by(serialnumber='USB-1').first()
device.isactive = False
kiosk.session.commit()
data = _session(client).get_json()['data']
assert data['available'] == []