Files
shopdb-flask/plugins/usb/frontend/views/USBSettings.vue
cproudlock 7f6aaf2e64 Gate the USB kiosk the same way, and make an unreachable setting a test failure
The USB kiosk had a feature flag and nothing else. A flag is a decision, not a
credential: with the kiosk on, anyone who could reach the app could move a drive
between checked-out and checked-in and sign it with a badge they guessed. Same
reasoning that closed the parts kiosk, smaller blast radius - which is why
Darkwing did not raise it and why it was worth asking about anyway.

Both kiosks now share one mechanism. The device token moved out of the
printedparts api wrapper into a request interceptor that attaches X-Kiosk-Token
by URL prefix, with one localStorage key per kiosk - they are separate screens
in separate rooms, and a shared token would mean provisioning one grants the
other. claimKioskToken() is shared too, so both views take the token from a
?token= URL once and strip it from the address bar identically.

Then the part that matters more than either fix. I told you to set the new
settings in the UI, and that was impossible: every plugin settings page carries
a hardcoded key list, and nothing had added them. printedparts_kiosk_enabled,
printedparts_kiosk_token and usb_kiosk_token were declared, seeded and read
correctly, and rendered by no page - changeable only by writing SQL.

tests/test_plugin_settings_reachable.py now fails when any declared setting
appears nowhere in its plugin's frontend. It found seven MORE that were already
in that state, all pre-existing:

  collector_corporate_ranges          computers_configpolicies_enabled
  computers_machinelink_alerts        computers_quietreporthours
  geenforce_reportstaleminutes        printers_dashboardpercent
  warranty_expiringdays

All seven now have fields. I first parked them in a KNOWN_GAPS ratchet, then
fixed them and deleted the ratchet - a backlog list in a new check is a way of
agreeing the check does not apply yet. There is no backlog; the check is
absolute.

Two of my own mistakes on the way, both caught by tests rather than by reading:
the blanket rename of the USB guard also rewrote the call inside _kiosk_open, so
it called itself until the stack ran out; and the first version of the reachable
check skipped all 17 plugins because it looked for get_settings() when the
method is get_settings_defaults() - it passed by testing nothing, which is the
exact shape it exists to catch.

Suite 2336, frontend 316, build clean.

Scope: product
2026-09-02 18:08:34 -04:00

153 lines
5.2 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 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'
const KEYS = ['usb_kiosk_enabled', 'usb_kiosk_allow_return_for_others',
'usb_kiosk_token']
const values = ref({
usb_kiosk_enabled: false,
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)) values.value[row.key] = 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) {
await settingsApi.update(key, values.value[key] ? 'true' : 'false')
}
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>