Files
shopdb-flask/plugins/usb/frontend/views/USBSettings.vue
cproudlock da91a29155 Admit kiosk callers by network position, not only by device token
The device token assumes a handful of fixed screens an administrator
provisions once. That is not how these kiosks are used: most people open the
page on their own PC, and there is no login, so per-browser provisioning would
mean touching every PC on the floor and losing it again on the next reimage or
cleared profile. A screen in that state did not look refused either - the USB
kiosk rendered a working page whose every call answered 404.

Both kiosks now admit a caller on either ground, the same shape the GE-Enforce
client endpoints already use: the per-device token, or a source address inside
an allowlist. Wall screens keep the token and are not confined to the
allowlist; everyone else is admitted by where they are on the network. Still
gated on the site's enable flag, so nothing opens by itself, and both
allowlists ship empty - token-only until a site enters its ranges.

- shopdb.api gains ip_in_cidrs and request_ip_allowlisted (contract 0.35.0).
  On the surface because both plugins need the identical gate, and a CIDR
  parser copied into two of them is where a fail-open creeps in later. They
  fail closed on an empty setting, an unparseable caller address and a
  malformed entry, and they read remote_addr rather than raw X-Forwarded-For -
  so the IIS rewrite rule is what makes them meaningful, which the module says
  at length.
- Separate settings per kiosk, usb_kiosk_allowed_cidrs and
  printedparts_kiosk_allowed_cidrs. One must not grant the other, for the same
  reason the tokens are separate.
- Both settings pages get a picker listing the network plugin's subnets with
  their VLANs. It stores resolved CIDR strings, not subnet ids: the setting has
  to work at a site that does not run that plugin and must not break when an
  inventory row is deleted. Entries matching no subnet are shown rather than
  hidden, since a stale one left by an inventory edit looks exactly like a
  hand-typed range.
- USBKiosk says it was not admitted instead of pretending to work, which
  PartsKiosk already did.
- The USB settings page keyed text-vs-boolean off the token alone. Adding a
  second text setting to the boolean path would have saved it as the word
  'true' - the bug that replaced the kiosk token and 404'd every provisioned
  screen. Both load and save now key off a list, with a spec on it.

The trade, stated plainly in the admin UI: 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. Keeping
the ranges narrow is what bounds it; login is the alternative and was ruled out
because these users do not sign in.
2026-09-16 14:33:22 -04:00

188 lines
7.0 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>
</div>
<p class="field-hint">
Provision each screen once by opening
<code>/usb-kiosk?token=&lt;this value&gt;</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 { 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('')
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>