Gate both kiosks behind a device token, and make an unreachable setting a test failure
This commit is contained in:
@@ -92,7 +92,7 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
||||
import { printedpartsApi, settingsApi, KIOSK_TOKEN_KEY } from '@/api'
|
||||
import { printedpartsApi, settingsApi, claimKioskToken, KIOSK_TOKEN_KEYS } from '@/api'
|
||||
import { withBase } from '@/utils/basePath'
|
||||
import TouchKeypad from '@/components/TouchKeypad.vue'
|
||||
|
||||
@@ -116,28 +116,8 @@ const scannedRevision = ref(null)
|
||||
const entryInput = ref(null)
|
||||
let resetTimer = null
|
||||
|
||||
// Claim the device token, once, from the URL an admin used to set this screen
|
||||
// up: /parts-kiosk?token=<value>. It is kept in THIS browser and stripped from
|
||||
// the address bar immediately, so it does not sit in the URL for a shoulder to
|
||||
// read, and does not travel in a bookmark or a screenshot. Every kiosk call
|
||||
// then carries it as X-Kiosk-Token; without it the routes answer 404.
|
||||
function claimTokenFromUrl() {
|
||||
try {
|
||||
const url = new URL(window.location.href)
|
||||
const token = (url.searchParams.get('token') || '').trim()
|
||||
if (!token) return
|
||||
localStorage.setItem(KIOSK_TOKEN_KEY, token)
|
||||
url.searchParams.delete('token')
|
||||
window.history.replaceState({}, '', url.pathname + url.search + url.hash)
|
||||
} catch {
|
||||
// A browser refusing localStorage is a browser this kiosk cannot be
|
||||
// provisioned in. Nothing to recover here; the routes will 404 and the
|
||||
// operator sees the standard error.
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
claimTokenFromUrl()
|
||||
claimKioskToken(KIOSK_TOKEN_KEYS['/printedparts/kiosk/'])
|
||||
// A kiosk left running for weeks reads this once at load, which is fine: the
|
||||
// label format is a property of the site's physical tags, not something that
|
||||
// changes during a shift.
|
||||
|
||||
@@ -36,6 +36,38 @@
|
||||
<p class="field-hint">Seed value for new items; each item can override.</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>
|
||||
<input type="checkbox" v-model="kioskEnabled" />
|
||||
Serve the parts kiosk
|
||||
</label>
|
||||
<p class="field-hint">
|
||||
Off by default. The kiosk routes are the only ones that write without
|
||||
a login, so a site opts in.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Kiosk device token</label>
|
||||
<div class="token-row">
|
||||
<input v-model="values.printedparts_kiosk_token" type="text"
|
||||
class="form-control" placeholder="not set" />
|
||||
<button type="button" class="btn btn-secondary" @click="generateToken">
|
||||
Generate
|
||||
</button>
|
||||
</div>
|
||||
<p class="field-hint">
|
||||
Each kiosk screen is provisioned once by opening
|
||||
<code>/parts-kiosk?token=<this value></code> on it. The browser
|
||||
keeps it; the address bar does not. A screen without it gets a 404,
|
||||
and so does anyone else who finds the route.
|
||||
</p>
|
||||
<p v-if="tokenJustGenerated" class="field-hint token-warn">
|
||||
Copy this now and save. It is masked once stored, so this is the only
|
||||
time it is shown.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Unknown badge at the kiosk</label>
|
||||
<select v-model="values.printedparts_unknown_badge" class="form-control">
|
||||
@@ -109,13 +141,15 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { settingsApi, usersApi, supportteamsApi } from '@/api'
|
||||
|
||||
const KEYS = [
|
||||
'printedparts_code_prefix',
|
||||
'printedparts_label_prefix',
|
||||
'printedparts_default_threshold',
|
||||
'printedparts_kiosk_enabled',
|
||||
'printedparts_kiosk_token',
|
||||
'printedparts_unknown_badge',
|
||||
'printedparts_alert_email',
|
||||
'printedparts_alert_userids',
|
||||
@@ -127,12 +161,33 @@ const values = ref({
|
||||
printedparts_code_prefix: '3DP',
|
||||
printedparts_label_prefix: '',
|
||||
printedparts_default_threshold: 5,
|
||||
printedparts_kiosk_enabled: 'false',
|
||||
printedparts_kiosk_token: '',
|
||||
printedparts_unknown_badge: 'deny',
|
||||
printedparts_alert_email: '',
|
||||
printedparts_alert_userids: '',
|
||||
printedparts_alert_roleids: '',
|
||||
printedparts_alert_supportteamid: ''
|
||||
})
|
||||
|
||||
// Settings are stored as strings. Bridge the checkbox to that rather than
|
||||
// letting a boolean reach the API and come back as the string 'True'.
|
||||
const kioskEnabled = computed({
|
||||
get: () => String(values.value.printedparts_kiosk_enabled).toLowerCase() === 'true',
|
||||
set: (on) => { values.value.printedparts_kiosk_enabled = on ? 'true' : 'false' },
|
||||
})
|
||||
|
||||
const tokenJustGenerated = ref(false)
|
||||
|
||||
// A token an operator would not guess and does not have to invent. Generated in
|
||||
// the browser so it never travels anywhere it did not need to.
|
||||
function generateToken() {
|
||||
const bytes = new Uint8Array(24)
|
||||
crypto.getRandomValues(bytes)
|
||||
values.value.printedparts_kiosk_token =
|
||||
Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('')
|
||||
tokenJustGenerated.value = true
|
||||
}
|
||||
const supportTeams = ref([])
|
||||
const users = ref([])
|
||||
const selectedUserids = ref([])
|
||||
@@ -188,6 +243,10 @@ async function save() {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.token-row { display: flex; gap: 8px; align-items: center; }
|
||||
.token-row .form-control { flex: 1 1 auto; }
|
||||
.token-warn { color: var(--warning-dark); }
|
||||
|
||||
.field-hint { color: var(--text-light); font-size: 0.85rem; margin-top: 0.25rem; }
|
||||
.user-picker {
|
||||
max-height: 12rem;
|
||||
|
||||
@@ -162,7 +162,11 @@ class PrintedpartsPlugin(BasePlugin):
|
||||
{
|
||||
'key': 'printedparts_kiosk_token',
|
||||
'value': '',
|
||||
'valuetype': 'secret',
|
||||
# string, not 'secret': the column documents string/boolean/
|
||||
# integer/json only. Masking is keyed on the NAME containing
|
||||
# 'token' (settings.py::_is_secret), so this is still hidden in
|
||||
# the API and the audit log.
|
||||
'valuetype': 'string',
|
||||
'category': 'printedparts',
|
||||
'description': 'Device token the kiosk sends as X-Kiosk-Token. '
|
||||
'Generate one, then open the kiosk once as '
|
||||
|
||||
Reference in New Issue
Block a user