From 6eb30285b4f8969a0ac04b8b6bdef956bca9e9eb Mon Sep 17 00:00:00 2001 From: cproudlock Date: Tue, 15 Sep 2026 16:02:51 -0400 Subject: [PATCH] Stop the USB settings page from replacing the kiosk token with "true" The page saved every field as 'true'/'false', the kiosk device token included, and loaded the token as a boolean. One save of Settings > USB Devices therefore stored the literal word "true" (or "false") as the token, and every kiosk provisioned with the real token got 404 on each request - reported as the kiosk no longer accepting an SSO. Generate + Save never stored the generated value either. The token now loads and saves as text. The server already keeps the stored token when it receives the mask back unchanged. The two switches still save as booleans. The parts kiosk page was not affected. --- plugins/usb/frontend/views/USBSettings.vue | 15 +++- .../usb/frontend/views/usbSettings.spec.js | 73 +++++++++++++++++++ 2 files changed, 86 insertions(+), 2 deletions(-) create mode 100644 plugins/usb/frontend/views/usbSettings.spec.js diff --git a/plugins/usb/frontend/views/USBSettings.vue b/plugins/usb/frontend/views/USBSettings.vue index 8a71afa7..62f22a95 100644 --- a/plugins/usb/frontend/views/USBSettings.vue +++ b/plugins/usb/frontend/views/USBSettings.vue @@ -114,7 +114,12 @@ 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) + if (!KEYS.includes(row.key)) continue + // The token is text (the server sends a mask once set); only the two + // switches are booleans. + values.value[row.key] = row.key === 'usb_kiosk_token' + ? (row.value || '') + : asBool(row.value) } } catch (loadError) { error.value = 'Could not load USB settings.' @@ -127,7 +132,13 @@ async function save () { error.value = '' try { for (const key of KEYS) { - await settingsApi.update(key, values.value[key] ? 'true' : 'false') + // Saving the token as true/false 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 = key === 'usb_kiosk_token' + ? String(values.value[key] ?? '') + : (values.value[key] ? 'true' : 'false') + await settingsApi.update(key, value) } message.value = 'Settings saved' } catch (saveError) { diff --git a/plugins/usb/frontend/views/usbSettings.spec.js b/plugins/usb/frontend/views/usbSettings.spec.js new file mode 100644 index 00000000..bacb8b00 --- /dev/null +++ b/plugins/usb/frontend/views/usbSettings.spec.js @@ -0,0 +1,73 @@ +/** + * USB kiosk settings save. + * + * The page used to send every field as 'true'/'false', token included, so one + * save replaced the kiosk device token with the word 'true' and every + * provisioned kiosk then got 404. The token must travel as text: the mask back + * unchanged (the server keeps the stored token) or a newly generated value. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { mount, flushPromises } from '@vue/test-utils' + +vi.mock('@/api', () => ({ + settingsApi: { list: vi.fn(), update: vi.fn() } +})) +vi.mock('@/utils/basePath', () => ({ withBase: (path) => path })) + +import { settingsApi } from '@/api' +import USBSettings from './USBSettings.vue' + +const MASK = '********' + +async function render(rows) { + settingsApi.list.mockResolvedValue({ data: { data: rows } }) + settingsApi.update.mockResolvedValue({ data: { status: 'success' } }) + const wrapper = mount(USBSettings) + await flushPromises() + return wrapper +} + +function saved() { + return Object.fromEntries(settingsApi.update.mock.calls) +} + +describe('USBSettings save', () => { + beforeEach(() => { + settingsApi.list.mockReset() + settingsApi.update.mockReset() + }) + + it('sends the masked token back unchanged, not true', async () => { + const wrapper = await render([ + { key: 'usb_kiosk_enabled', value: 'true' }, + { key: 'usb_kiosk_allow_return_for_others', value: 'false' }, + { key: 'usb_kiosk_token', value: MASK }, + ]) + await wrapper.find('button.btn-primary').trigger('click') + await flushPromises() + expect(saved()).toEqual({ + usb_kiosk_enabled: 'true', + usb_kiosk_allow_return_for_others: 'false', + usb_kiosk_token: MASK, + }) + }) + + it('saves a generated token as the token itself', async () => { + const wrapper = await render([ + { key: 'usb_kiosk_enabled', value: 'true' }, + { key: 'usb_kiosk_token', value: '' }, + ]) + const generate = wrapper.findAll('button').find(b => b.text() === 'Generate') + await generate.trigger('click') + await wrapper.find('button.btn-primary').trigger('click') + await flushPromises() + expect(saved().usb_kiosk_token).toMatch(/^[0-9a-f]{48}$/) + }) + + it('keeps an unset token empty', async () => { + const wrapper = await render([{ key: 'usb_kiosk_token', value: '' }]) + await wrapper.find('button.btn-primary').trigger('click') + await flushPromises() + expect(saved().usb_kiosk_token).toBe('') + }) +})