diff --git a/plugins/printedparts/api/routes.py b/plugins/printedparts/api/routes.py index bfd1a8a6..5d895bfc 100644 --- a/plugins/printedparts/api/routes.py +++ b/plugins/printedparts/api/routes.py @@ -518,7 +518,7 @@ def adjust_item(item_id: int): import hmac -from shopdb.api import request_ip_allowlisted +from shopdb.api import request_ip_allowlisted, audit_log # Largest single take the kiosk records. A shop-floor grab is single digits; 25 # leaves real use untouched and denies draining a bin in one request. @@ -557,6 +557,27 @@ def _kiosk_open(): request, Setting.get('printedparts_kiosk_allowed_cidrs')) +@printedparts_bp.route('/kiosk/provisioning-link', methods=['GET']) +@require_permission('settings.edit') +def kiosk_provisioning_link(): + """The URL that provisions a screen, token included, for an administrator. + + Same reason as the USB kiosk: the settings API masks any key named + '*token*', so the value is visible for one moment after Generate and an + administrator who copies the field later provisions a screen with + '********' and gets a silent 404. This hands the real value to somebody + already allowed to set it, so the mask can stay. + """ + token = (Setting.get('printedparts_kiosk_token') or '').strip() + if not token: + return error_response(ErrorCodes.VALIDATION_ERROR, + 'No kiosk token set. Generate one first.') + audit_log('viewed', 'Setting', entityname='printedparts_kiosk_token', + details='kiosk provisioning link') + db.session.commit() + return success_response({'url': '/parts-kiosk?token=' + token}) + + def _kiosk_shut(): """404, not 403 - a caller without the token does not learn the door exists.""" return error_response(ErrorCodes.NOT_FOUND, 'Not found', http_code=404) diff --git a/plugins/printedparts/frontend/views/PrintedPartsSettings.vue b/plugins/printedparts/frontend/views/PrintedPartsSettings.vue index 2730d0a8..c635fac7 100644 --- a/plugins/printedparts/frontend/views/PrintedPartsSettings.vue +++ b/plugins/printedparts/frontend/views/PrintedPartsSettings.vue @@ -55,7 +55,13 @@ + +

{{ linkMessage }}

+

Each kiosk screen is provisioned once by opening /parts-kiosk?token=<this value> on it. The browser @@ -156,6 +162,33 @@ import { ref, computed, onMounted } from 'vue' import { settingsApi, usersApi, supportteamsApi } from '@/api' import NetworkAllowlistPicker from '@/components/NetworkAllowlistPicker.vue' +import api from '@/api' +import { withBase } from '@/utils/basePath' + +// The settings API masks the token, so the field shows '********'. Copying that +// provisions a screen with the mask and every kiosk call answers 404 with +// nothing to say why. Ask the server for the real link instead. +const link = ref('') +const linkMessage = ref('') + +async function copyLink () { + linkMessage.value = '' + link.value = '' + try { + const response = await api.get('/printedparts/kiosk/provisioning-link') + const url = window.location.origin + withBase(response.data.data.url) + link.value = url + try { + await navigator.clipboard.writeText(url) + linkMessage.value = 'Copied. Open this once on the kiosk screen.' + } catch (clipboardError) { + linkMessage.value = 'Copy this and open it once on the kiosk screen.' + } + } catch (linkError) { + linkMessage.value = linkError.response?.data?.data?.error?.message + || 'Could not build the setup link.' + } +} const KEYS = [ 'printedparts_code_prefix', diff --git a/plugins/usb/api/routes.py b/plugins/usb/api/routes.py index 064da060..b1c893ce 100644 --- a/plugins/usb/api/routes.py +++ b/plugins/usb/api/routes.py @@ -39,7 +39,7 @@ from shopdb.api import ( get_pagination_params, require_permission, ) -from shopdb.api import Setting, request_ip_allowlisted +from shopdb.api import Setting, request_ip_allowlisted, audit_log, db from . import selfhosted @@ -815,6 +815,40 @@ def _kiosk_badge(data): return badge, None +@usb_bp.route('/kiosk/provisioning-link', methods=['GET']) +@require_permission('settings.edit') +def kiosk_provisioning_link(): + """The URL that provisions a screen, token included, for an administrator. + + WHY THIS EXISTS. The settings API masks every key whose name contains + 'token', so the settings page shows the kiosk token as '********'. The real + value is visible for exactly one moment - after Generate, before Save - and + an administrator who copies the field at any other time provisions a screen + with the literal string '********'. Nothing says so: the kiosk answers 404 + the same way it answers a caller it has never heard of, because the route + deliberately does not advertise itself. That has cost real time. + + So the copy step stops depending on catching that moment. The mask stays - + it is right that a token does not travel in an ordinary settings list - and + this named, permission-gated route hands the value to somebody who is + already allowed to set it. + + Returns a relative URL. The caller's own origin is the right base for a + screen being provisioned from that same browser, and the server has no + reliable idea what hostname the operator reached it by. + """ + token = (Setting.get('usb_kiosk_token') or '').strip() + if not token: + return error_response(ErrorCodes.VALIDATION_ERROR, + 'No kiosk token set. Generate one first.') + # Revealing a credential is worth a line in the audit log: it is the moment + # the value left the database, and the trail should say who asked. + audit_log('viewed', 'Setting', entityname='usb_kiosk_token', + details='kiosk provisioning link') + db.session.commit() + return success_response({'url': '/usb-kiosk?token=' + token}) + + @usb_bp.route('/kiosk/device/', methods=['GET']) def kiosk_device(device_id): """What the kiosk shows after a device is scanned. diff --git a/plugins/usb/frontend/views/USBKiosk.vue b/plugins/usb/frontend/views/USBKiosk.vue index a4a2e569..0b4105a9 100644 --- a/plugins/usb/frontend/views/USBKiosk.vue +++ b/plugins/usb/frontend/views/USBKiosk.vue @@ -177,8 +177,33 @@ onMounted(async () => { // An unreachable setting is not proof it is off; let the API decide. disabled.value = false } + if (!disabled.value) await probeAdmission() focusEntry() }) + +// ASK, rather than guess, whether this screen may use the kiosk. +// +// The enabled flag is public and says only that the site HAS a kiosk; whether +// THIS caller is admitted is decided per request, and the server answers 404 +// rather than 403 so the route does not advertise itself. Without asking, the +// page rendered a keypad and only failed once somebody had typed their SSO - +// which reads as broken rather than as not set up. +// +// The parts kiosk answers the same question by checking whether its browser +// holds a token. That test is wrong here: a PC admitted by network carries no +// token and is perfectly entitled, so it would warn exactly the callers the +// allowlist exists to serve. Hence a real request. +// +// An empty badge is deliberate. The gate runs before any badge handling, so a +// refused caller gets 404 and an admitted one gets the badge validation error - +// no lookup, no write, nothing recorded either way. +async function probeAdmission () { + try { + await api.post('/usb/kiosk/session', { badge: '' }) + } catch (probeError) { + if (probeError.response?.status === 404) refused.value = true + } +} onBeforeUnmount(() => clearTimeout(resetTimer)) function focusEntry (event) { diff --git a/plugins/usb/frontend/views/USBSettings.vue b/plugins/usb/frontend/views/USBSettings.vue index b08219a4..4182058e 100644 --- a/plugins/usb/frontend/views/USBSettings.vue +++ b/plugins/usb/frontend/views/USBSettings.vue @@ -41,7 +41,13 @@ + +

{{ linkMessage }}

+

Provision each screen once by opening /usb-kiosk?token=<this value> on it. That browser @@ -97,7 +103,7 @@