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
{{ linkMessage }}
Provision each screen 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//usb-kiosk?token=<this value> on it. That browser
@@ -97,7 +103,7 @@