USB kiosk: guest check-out/check-in without a login
This commit is contained in:
@@ -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))
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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',
|
||||
|
||||
327
plugins/usb/frontend/views/USBKiosk.vue
Normal file
327
plugins/usb/frontend/views/USBKiosk.vue
Normal 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>
|
||||
@@ -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)."""
|
||||
|
||||
Reference in New Issue
Block a user