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.
317 lines
12 KiB
Vue
317 lines
12 KiB
Vue
<template>
|
|
<div>
|
|
<div class="page-header">
|
|
<h2>3D Printed Parts</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>Item code prefix</label>
|
|
<input v-model="values.printedparts_code_prefix" type="text"
|
|
class="form-control" maxlength="8" />
|
|
<p class="field-hint">
|
|
New items mint codes like {{ values.printedparts_code_prefix || '3DP' }}0042.
|
|
Changing it does not rename existing items.
|
|
</p>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label>Kiosk label prefix</label>
|
|
<input v-model="values.printedparts_label_prefix" type="text"
|
|
class="form-control" maxlength="8" placeholder="none" />
|
|
<p class="field-hint">
|
|
Leading text on this site's physical labels, shown at the kiosk before the
|
|
number box so operators type only the digits. Leave it empty to show no
|
|
prefix. This is about the printed tags, not the item codes above.
|
|
</p>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label>Default low-stock threshold</label>
|
|
<input v-model.number="values.printedparts_default_threshold"
|
|
type="number" min="0" class="form-control" />
|
|
<p class="field-hint">Seed value for new items; each item can override.</p>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label>
|
|
<input type="checkbox" v-model="kioskEnabled" />
|
|
Serve the parts kiosk
|
|
</label>
|
|
<p class="field-hint">
|
|
Off by default. The kiosk routes are the only ones that write without
|
|
a login, so a site opts in.
|
|
</p>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label>Kiosk device token</label>
|
|
<div class="token-row">
|
|
<input v-model="values.printedparts_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">
|
|
Each kiosk screen is provisioned once by opening
|
|
<code>/parts-kiosk?token=<this value></code> on it. The browser
|
|
keeps it; the address bar does not. A screen without it gets a 404,
|
|
and so does anyone else who finds the route.
|
|
</p>
|
|
<p v-if="tokenJustGenerated" class="field-hint token-warn">
|
|
Copy this now and save. It is masked once stored, so this is the only
|
|
time it is shown.
|
|
</p>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label>Networks allowed without the token</label>
|
|
<NetworkAllowlistPicker v-model="values.printedparts_kiosk_allowed_cidrs" />
|
|
<p class="field-hint">
|
|
For people who open the kiosk on their own PC rather than at the
|
|
screen. A browser on one of these networks is admitted with no token
|
|
and no login, so keep this to the shop-floor ranges. Leave empty to
|
|
require the token from every caller. Separate from the USB kiosk
|
|
allowlist: one does not grant the other.
|
|
</p>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label>Unknown badge at the kiosk</label>
|
|
<select v-model="values.printedparts_unknown_badge" class="form-control">
|
|
<option value="deny">Deny - refuse badges with no directory match</option>
|
|
<option value="allow">Allow - record the SSO with no name</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label>Alert shopdb users</label>
|
|
<div class="user-picker">
|
|
<label v-for="candidate in users" :key="candidate.userid" class="user-row">
|
|
<input type="checkbox" :value="String(candidate.userid)"
|
|
v-model="selectedUserids" />
|
|
<span>{{ candidate.username }}</span>
|
|
<span class="user-email">{{ candidate.email }}</span>
|
|
</label>
|
|
<p v-if="users.length === 0" class="field-hint">No users loaded</p>
|
|
</div>
|
|
<p class="field-hint">
|
|
Selected users receive low-stock alerts at their account email.
|
|
</p>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label>Alert roles</label>
|
|
<div class="user-picker">
|
|
<label v-for="role in roles" :key="role.roleid" class="user-row">
|
|
<input type="checkbox" :value="String(role.roleid)"
|
|
v-model="selectedRoleids" />
|
|
<span>{{ role.rolename }}</span>
|
|
<span class="user-email">{{ role.description }}</span>
|
|
</label>
|
|
<p v-if="roles.length === 0" class="field-hint">No roles loaded</p>
|
|
</div>
|
|
<p class="field-hint">
|
|
Every active member of a selected role receives low-stock alerts.
|
|
</p>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label>Additional alert emails</label>
|
|
<input v-model="values.printedparts_alert_email" type="text"
|
|
class="form-control" placeholder="parts-team@example.com, lead@example.com" />
|
|
<p class="field-hint">
|
|
Comma-separated. Empty uses the site-wide alert recipients
|
|
(Settings > System > Email). Alerts fire once when an item
|
|
crosses its threshold; restocking above re-arms.
|
|
</p>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label>Alert support team (Teams webhook)</label>
|
|
<select v-model="values.printedparts_alert_supportteamid" class="form-control">
|
|
<option value="">Site default webhook</option>
|
|
<option v-for="team in supportTeams" :key="team.supportteamid" :value="String(team.supportteamid)">
|
|
{{ team.teamname }}{{ team.webhookurl ? '' : ' (no webhook set)' }}
|
|
</option>
|
|
</select>
|
|
<p class="field-hint">
|
|
Low-stock alerts post to this team's webhook (set on Settings >
|
|
Support Teams). Empty uses the site-wide alert webhook.
|
|
</p>
|
|
</div>
|
|
|
|
<button class="btn btn-primary" :disabled="saving" @click="save">
|
|
{{ saving ? 'Saving...' : 'Save' }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, computed, onMounted } from 'vue'
|
|
import { settingsApi, usersApi, supportteamsApi } from '@/api'
|
|
import NetworkAllowlistPicker from '@/components/NetworkAllowlistPicker.vue'
|
|
import api from '@/api'
|
|
import { withBase } from '@/utils/basePath'
|
|
|
|
// The settings API masks the token, so the field shows '********'. Copying that
|
|
// provisions a screen with the mask and every kiosk call answers 404 with
|
|
// nothing to say why. Ask the server for the real link instead.
|
|
const link = ref('')
|
|
const linkMessage = ref('')
|
|
|
|
async function copyLink () {
|
|
linkMessage.value = ''
|
|
link.value = ''
|
|
try {
|
|
const response = await api.get('/printedparts/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) {
|
|
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.'
|
|
}
|
|
}
|
|
|
|
const KEYS = [
|
|
'printedparts_code_prefix',
|
|
'printedparts_label_prefix',
|
|
'printedparts_default_threshold',
|
|
'printedparts_kiosk_enabled',
|
|
'printedparts_kiosk_token',
|
|
'printedparts_kiosk_allowed_cidrs',
|
|
'printedparts_unknown_badge',
|
|
'printedparts_alert_email',
|
|
'printedparts_alert_userids',
|
|
'printedparts_alert_roleids',
|
|
'printedparts_alert_supportteamid'
|
|
]
|
|
|
|
const values = ref({
|
|
printedparts_code_prefix: '3DP',
|
|
printedparts_label_prefix: '',
|
|
printedparts_default_threshold: 5,
|
|
printedparts_kiosk_enabled: 'false',
|
|
printedparts_kiosk_token: '',
|
|
printedparts_kiosk_allowed_cidrs: '',
|
|
printedparts_unknown_badge: 'deny',
|
|
printedparts_alert_email: '',
|
|
printedparts_alert_userids: '',
|
|
printedparts_alert_roleids: '',
|
|
printedparts_alert_supportteamid: ''
|
|
})
|
|
|
|
// Settings are stored as strings. Bridge the checkbox to that rather than
|
|
// letting a boolean reach the API and come back as the string 'True'.
|
|
const kioskEnabled = computed({
|
|
get: () => String(values.value.printedparts_kiosk_enabled).toLowerCase() === 'true',
|
|
set: (on) => { values.value.printedparts_kiosk_enabled = on ? 'true' : 'false' },
|
|
})
|
|
|
|
const tokenJustGenerated = ref(false)
|
|
|
|
// A token an operator would not guess and does not have to invent. 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.printedparts_kiosk_token =
|
|
Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('')
|
|
tokenJustGenerated.value = true
|
|
}
|
|
const supportTeams = ref([])
|
|
const users = ref([])
|
|
const selectedUserids = ref([])
|
|
const roles = ref([])
|
|
const selectedRoleids = ref([])
|
|
const saving = ref(false)
|
|
const message = ref('')
|
|
const error = ref('')
|
|
|
|
onMounted(async () => {
|
|
try {
|
|
const response = await settingsApi.list({ category: 'printedparts' })
|
|
const rows = response.data.data || []
|
|
for (const row of rows) {
|
|
if (KEYS.includes(row.key)) values.value[row.key] = row.value
|
|
}
|
|
values.value.printedparts_default_threshold =
|
|
parseInt(values.value.printedparts_default_threshold, 10) || 0
|
|
selectedUserids.value = (values.value.printedparts_alert_userids || '')
|
|
.split(',').map(id => id.trim()).filter(Boolean)
|
|
const usersResponse = await usersApi.list()
|
|
users.value = (usersResponse.data.data || []).filter(
|
|
candidate => candidate.isactive && candidate.email)
|
|
selectedRoleids.value = (values.value.printedparts_alert_roleids || '')
|
|
.split(',').map(id => id.trim()).filter(Boolean)
|
|
const rolesResponse = await usersApi.roles.list()
|
|
roles.value = rolesResponse.data.data || []
|
|
const teamsResponse = await supportteamsApi.list()
|
|
supportTeams.value = teamsResponse.data.data || []
|
|
} catch (loadError) {
|
|
error.value = 'Could not load settings'
|
|
console.error(loadError)
|
|
}
|
|
})
|
|
|
|
async function save() {
|
|
saving.value = true
|
|
message.value = ''
|
|
error.value = ''
|
|
try {
|
|
values.value.printedparts_alert_userids = selectedUserids.value.join(',')
|
|
values.value.printedparts_alert_roleids = selectedRoleids.value.join(',')
|
|
for (const key of KEYS) {
|
|
await settingsApi.update(key, String(values.value[key] ?? ''))
|
|
}
|
|
message.value = 'Settings saved'
|
|
} catch (saveError) {
|
|
error.value = saveError.response?.data?.data?.error?.message || 'Save failed'
|
|
} finally {
|
|
saving.value = false
|
|
}
|
|
}
|
|
</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; }
|
|
.user-picker {
|
|
max-height: 12rem;
|
|
overflow-y: auto;
|
|
border: 1px solid var(--border);
|
|
border-radius: 0.35rem;
|
|
padding: 0.5rem;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.35rem;
|
|
}
|
|
.user-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
cursor: pointer;
|
|
}
|
|
.user-email { color: var(--text-light); font-size: 0.85rem; }
|
|
</style>
|