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.
188 lines
7.0 KiB
Vue
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=<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 { 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>
|