Two failures from the same root: an administrator cannot see the kiosk token, and a screen that was provisioned wrongly does not say so. The settings API masks every key whose name contains 'token', so the settings page shows '********'. The real value is visible for one moment - after Generate, before Save - and an administrator who copies the field at any other time provisions a screen with the literal mask. The kiosk then answers 404, exactly as it answers a caller it has never heard of, because the route deliberately does not advertise itself. Nothing anywhere says what went wrong. - Each kiosk gains a permission-gated provisioning-link route that returns the real setup URL, token included, to somebody who already holds settings.edit. The mask stays: a token has no business in an ordinary settings list. The copy step just stops depending on catching the one moment it is on screen. Revealing it writes an audit row, since that is when the value left the database. Both settings pages get a "Copy setup link" button, which falls back to showing the link when the clipboard is refused - it is, over plain http and in locked-down browsers. - The USB kiosk asks the server on load whether this screen may be used, and says plainly when it may not. It rendered a keypad and only failed once somebody had typed their SSO, which reads as broken rather than as not set up. It cannot guess: the enabled flag is public and only says the site has a kiosk, and a caller admitted by network holds no token to look for, so the parts kiosk's localStorage check would warn exactly the callers the allowlist exists to serve. The probe is a badge-less session request - the gate runs before any badge handling, so refused is 404 and admitted is the validation error, with no lookup and nothing recorded. A test pins that ordering, because reordering it would start telling admitted screens they are not set up. Verified in a browser, not only in tests: provisioning with the mask lands on the refused screen, a correct token gets the drive list and survives a plain revisit, and a tokenless caller inside the allowlist gets straight in.
223 lines
8.5 KiB
Vue
223 lines
8.5 KiB
Vue
<template>
|
|
<div>
|
|
<div class="page-header">
|
|
<h2>USB Devices</h2>
|
|
</div>
|
|
|
|
<div class="card form-card">
|
|
<div v-if="message" class="settings-success">{{ message }}</div>
|
|
<div v-if="error" class="error-message">{{ error }}</div>
|
|
|
|
<div class="form-group">
|
|
<label class="check-row">
|
|
<input type="checkbox" v-model="values.usb_kiosk_enabled" />
|
|
<span>Allow the logged-out USB kiosk</span>
|
|
</label>
|
|
<p class="field-hint">
|
|
Turns on <code>/usb-kiosk</code>, a full-screen touch page where somebody
|
|
at the drive cabinet takes and returns a drive without signing in.
|
|
It is off by default: this is a write nobody has logged in for, so it is
|
|
a decision a site makes deliberately. While it is off, every kiosk
|
|
request is refused and the page says so.
|
|
</p>
|
|
<p class="field-hint">
|
|
The kiosk can only move a drive between checked out and checked in. It
|
|
cannot add, edit or retire one. Each action is attributed to the SSO
|
|
entered on the screen, and an SSO that matches no employee is refused -
|
|
a checkout nobody can trace to a person defeats the point of tracking
|
|
it.
|
|
</p>
|
|
<p v-if="values.usb_kiosk_enabled" class="field-hint">
|
|
<a :href="withBase('/usb-kiosk')" target="_blank">Open the kiosk</a>
|
|
- also in the sidebar under Displays.
|
|
</p>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label>Kiosk device token</label>
|
|
<div class="token-row">
|
|
<input v-model="values.usb_kiosk_token" type="text"
|
|
class="form-control" placeholder="not set" />
|
|
<button type="button" class="btn btn-secondary" @click="generateToken">
|
|
Generate
|
|
</button>
|
|
<button type="button" class="btn btn-secondary" @click="copyLink">
|
|
Copy setup link
|
|
</button>
|
|
</div>
|
|
<p v-if="linkMessage" class="field-hint">{{ linkMessage }}</p>
|
|
<input v-if="link" :value="link" type="text" class="form-control"
|
|
readonly @focus="$event.target.select()" />
|
|
<p class="field-hint">
|
|
Provision each screen once by opening
|
|
<code>/usb-kiosk?token=<this value></code> on it. That browser
|
|
keeps it; the address bar does not. Turning the kiosk on is a
|
|
decision, not a credential - without this token the routes answer 404,
|
|
so nobody who merely finds the URL can move a drive and sign it with a
|
|
badge they guessed.
|
|
</p>
|
|
<p v-if="tokenJustGenerated" class="field-hint token-warn">
|
|
Copy this now and save. It is masked once stored.
|
|
</p>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label>Networks allowed without the token</label>
|
|
<NetworkAllowlistPicker v-model="values.usb_kiosk_allowed_cidrs" />
|
|
<p class="field-hint">
|
|
For people who open the kiosk on their own PC. A browser on one of
|
|
these networks is admitted with no token and no login, so keep this to
|
|
the shop-floor ranges rather than everything. Leave empty to require
|
|
the token from every caller. Wall screens keep using the token either
|
|
way.
|
|
</p>
|
|
<p class="field-hint token-warn">
|
|
Anyone on an allowed network can check a drive in or out against a
|
|
badge number they guess, and the record carries that person's name.
|
|
Badges are attribution, not authorization.
|
|
</p>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label class="check-row">
|
|
<input type="checkbox" v-model="values.usb_kiosk_allow_return_for_others"
|
|
:disabled="!values.usb_kiosk_enabled" />
|
|
<span>Let somebody return a drive checked out to another person</span>
|
|
</label>
|
|
<p class="field-hint">
|
|
The kiosk asks first and names the holder, then records the SSO that
|
|
actually handed the drive back. Turn this off to refuse a mismatch
|
|
outright - but then a drive left behind by somebody on leave cannot be
|
|
returned at the kiosk at all, and needs a signed-in user to sort out.
|
|
</p>
|
|
</div>
|
|
|
|
<div class="form-actions">
|
|
<button class="btn btn-primary" :disabled="saving" @click="save">
|
|
{{ saving ? 'Saving...' : 'Save' }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, onMounted } from 'vue'
|
|
import api, { settingsApi } from '@/api'
|
|
import { withBase } from '@/utils/basePath'
|
|
import NetworkAllowlistPicker from '@/components/NetworkAllowlistPicker.vue'
|
|
|
|
const KEYS = ['usb_kiosk_enabled', 'usb_kiosk_allow_return_for_others',
|
|
'usb_kiosk_token', 'usb_kiosk_allowed_cidrs']
|
|
|
|
// Keys whose value is text. Everything else is a switch stored as true/false.
|
|
// Saving a text setting through the boolean path is what replaced the kiosk
|
|
// token with the word 'true' and 404'd every provisioned screen.
|
|
const TEXTKEYS = ['usb_kiosk_token', 'usb_kiosk_allowed_cidrs']
|
|
|
|
const values = ref({
|
|
usb_kiosk_enabled: false,
|
|
usb_kiosk_allowed_cidrs: '',
|
|
usb_kiosk_allow_return_for_others: true,
|
|
usb_kiosk_token: '',
|
|
})
|
|
|
|
const tokenJustGenerated = ref(false)
|
|
|
|
// 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.usb_kiosk_token =
|
|
Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('')
|
|
tokenJustGenerated.value = true
|
|
}
|
|
const saving = ref(false)
|
|
const message = ref('')
|
|
const error = ref('')
|
|
|
|
// The settings API masks the token, so the field above shows '********' rather
|
|
// than the value. Copying that provisions a screen with the mask and every
|
|
// kiosk call answers 404 with nothing to say why. This asks the server for the
|
|
// real link instead, so the copy step no longer depends on catching the one
|
|
// moment after Generate when the value is on screen.
|
|
const link = ref('')
|
|
const linkMessage = ref('')
|
|
|
|
async function copyLink () {
|
|
linkMessage.value = ''
|
|
link.value = ''
|
|
try {
|
|
const response = await api.get('/usb/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) {
|
|
// Clipboard access is refused over plain http and in some locked-down
|
|
// browsers. The link is shown below either way, so this is not a failure.
|
|
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.'
|
|
}
|
|
}
|
|
|
|
function asBool (value) {
|
|
return String(value).toLowerCase() === 'true' || value === true || value === 1
|
|
}
|
|
|
|
async function load () {
|
|
try {
|
|
const response = await settingsApi.list({ category: 'usb' })
|
|
for (const row of (response.data.data || [])) {
|
|
if (!KEYS.includes(row.key)) continue
|
|
// Text settings stay text (the server sends a mask for the token once it
|
|
// is set); only the switches are booleans.
|
|
values.value[row.key] = TEXTKEYS.includes(row.key)
|
|
? (row.value || '')
|
|
: asBool(row.value)
|
|
}
|
|
} catch (loadError) {
|
|
error.value = 'Could not load USB settings.'
|
|
}
|
|
}
|
|
|
|
async function save () {
|
|
saving.value = true
|
|
message.value = ''
|
|
error.value = ''
|
|
try {
|
|
for (const key of KEYS) {
|
|
// Saving a text setting through the boolean path replaced the real token
|
|
// with the word 'true', and every provisioned kiosk then got 404. The
|
|
// server keeps the stored token when it receives the mask back unchanged.
|
|
const value = TEXTKEYS.includes(key)
|
|
? String(values.value[key] ?? '')
|
|
: (values.value[key] ? 'true' : 'false')
|
|
await settingsApi.update(key, value)
|
|
}
|
|
message.value = 'Settings saved'
|
|
} catch (saveError) {
|
|
error.value = saveError.response?.data?.data?.error?.message || 'Save failed'
|
|
} finally {
|
|
saving.value = false
|
|
}
|
|
}
|
|
|
|
onMounted(load)
|
|
</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; }
|
|
.check-row { display: flex; align-items: center; gap: 0.5rem; font-weight: 600; }
|
|
.check-row input { width: auto; }
|
|
.form-actions { margin-top: 1rem; }
|
|
</style>
|