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

@@ -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)."""