USB kiosk: guest check-out/check-in without a login

This commit is contained in:
2026-08-27 08:19:03 -04:00
parent 0738d71525
commit 2326f8f096
9 changed files with 1236 additions and 0 deletions

View File

@@ -12,6 +12,39 @@ ADR-007 and ADR-002.
### Added
- **USB kiosk: check a drive out and back in without a login.** The people who
actually take USB drives are standing at the cabinet, and most of them have no
ShopDB account - so check-out and check-in were gated behind a login that was
not doing much anyway, since both modes already attribute the action to a
scanned BADGE rather than to the signed-in user. `/usb-kiosk` is a full-screen
touch screen modelled on the parts kiosk, and works against the self-hosted
tables or an existing `cmmc_usb` database.
It meets the same bar as the parts kiosk: it can move a device between
checked-out and checked-in and nothing else - no create, edit or retire - and
it does not exist at all until a site sets `usb_kiosk_enabled`. An
unauthenticated write is a decision about the door.
The drives carry no barcode, so unlike the parts kiosk there is nothing to
present and the screen has to offer a LIST. That widens what a logged-out
screen exposes, so the list is not anonymous: an SSO comes first and has to
resolve to a real person before any drive is named - the same bar taking one
already had to clear. The main screen shows the caller's own drives, what is
free, and a COUNT of everything else; the holders of other people's drives
appear only behind a deliberate second tap, so a passer-by cannot read the
whole cabinet off a wall panel.
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 point, and at a kiosk there is no operator standing there to vouch for it.
Returning somebody else's drive asks first, then records the badge that was
scanned - so the log says who actually handed it back, which is the honest
record and needs no change to a schema this app does not own. Refusing a
mismatch outright is available as a setting, but is not the default: a drive
left behind by somebody on leave would then be unreturnable at the kiosk,
which just moves the problem to a person with a login.
- **Audit log retention.** The audit log is append-only and nothing ever removed
a row, so it grew for as long as a site ran - here to the point where the
audit page was tens of thousands of pages deep and therefore useless, because

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'] == []